Initial commit

This commit is contained in:
Stedoss 2023-12-07 00:20:59 +00:00
commit 284a36412d
66 changed files with 7591 additions and 0 deletions

353
.gitignore vendored Normal file
View File

@ -0,0 +1,353 @@
# Created by https://www.toptal.com/developers/gitignore/api/aspnetcore,rider
# Edit at https://www.toptal.com/developers/gitignore?templates=aspnetcore,rider
### ASPNETCore ###
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
# User-specific files
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
# Visual Studio 2015 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUNIT
*.VisualState.xml
TestResult.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# DNX
project.lock.json
project.fragment.lock.json
artifacts/
*_i.c
*_p.c
*_i.h
*.ilk
*.meta
*.obj
*.pch
*.pdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*.log
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# JustCode is a .NET coding add-in
.JustCode
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# TODO: Comment the next line if you want to checkin your web deploy settings
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# The packages folder can be ignored because of Package Restore
**/packages/*
# except build/, which is used as an MSBuild target.
!**/packages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/packages/repositories.config
# NuGet v3's project.json files produces more ignoreable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
node_modules/
orleans.codegen.cs
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
# SQL Server files
*.mdf
*.ldf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# JetBrains Rider
.idea/
*.sln.iml
# CodeRush
.cr/
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/
### Rider ###
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
# User-specific stuff
.idea/**/workspace.xml
.idea/**/tasks.xml
.idea/**/usage.statistics.xml
.idea/**/dictionaries
.idea/**/shelf
# AWS User-specific
.idea/**/aws.xml
# Generated files
.idea/**/contentModel.xml
# Sensitive or high-churn files
.idea/**/dataSources/
.idea/**/dataSources.ids
.idea/**/dataSources.local.xml
.idea/**/sqlDataSources.xml
.idea/**/dynamic.xml
.idea/**/uiDesigner.xml
.idea/**/dbnavigator.xml
# Gradle
.idea/**/gradle.xml
.idea/**/libraries
# Gradle and Maven with auto-import
# When using Gradle or Maven with auto-import, you should exclude module files,
# since they will be recreated, and may cause churn. Uncomment if using
# auto-import.
# .idea/artifacts
# .idea/compiler.xml
# .idea/jarRepositories.xml
# .idea/modules.xml
# .idea/*.iml
# .idea/modules
# *.iml
# *.ipr
# CMake
cmake-build-*/
# Mongo Explorer plugin
.idea/**/mongoSettings.xml
# File-based project format
*.iws
# IntelliJ
out/
# mpeltonen/sbt-idea plugin
.idea_modules/
# JIRA plugin
atlassian-ide-plugin.xml
# Cursive Clojure plugin
.idea/replstate.xml
# SonarLint plugin
.idea/sonarlint/
# Crashlytics plugin (for Android Studio and IntelliJ)
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
# Editor-based Rest Client
.idea/httpRequests
# Android studio 3.1+ serialized cache file
.idea/caches/build_file_checksums.ser
# End of https://www.toptal.com/developers/gitignore/api/aspnetcore,rider

16
README.md Normal file
View File

@ -0,0 +1,16 @@
# YPS Beer
## Development and Running
To view the steps to run the application, please visit the `frontend` and `backend` directories respectively.
## How to use the application
Upon running both services, opening the frontend application at `http://localhost:3000` will show a login screen.
You will need to create an account, which can be done by clicking on the `Sign up now` button on the login form. Once you've created an account, it will redirect you to the login screen where you can login with the credentials you just entered.
You can then freely use the app!
## Feedback
I found this task to actually be quite challenging due to the inclusion of authentication - I have only used 3rd party services to integrate auth, mainly Auth0 and NextAuth. So, this was quite new for me and I did run into some pitfalls.
Please, do feel free to provide as much feedback as possible for me also on this!

37
backend/README.md Normal file
View File

@ -0,0 +1,37 @@
# YPS Beer (backend)
A super-simple backend for viewing, searching and favouring Beers from the [PunkAPI](https://punkapi.com/).
## Setup
### Requirements
To run in development mode, you will need:
* [.NET 8](https://dotnet.microsoft.com/en-us/download/dotnet/8.0)
> NOTE: By default, the app will run with an `InMemory` database, meaning all data is lost when shutting down the server. When you restart the server, you will need to re-create your account and all login sessions will be lost.
### Running locally
To run in development mode, run:
```sh
dotnet run
```
### Configuring
The app should already be setup to work in development mode with the frontend. For configuration options, use `YPS.Beer/appsettings{.Development}.json`
## Technology
To create this project, the following was used:
* .NET 8
* Identity Server
* Entity Framework
* NUnit & NSubstitute
## To improve:
### Testing
I would have preferred to get better integration testing in this project instead of just unit tests, given more time.
### Better Error Handling
There are places where the error handling isn't the best, and instead of returning generic responses it should be returning more specific ones.

View File

@ -0,0 +1,94 @@
using Microsoft.AspNetCore.Mvc;
using NSubstitute;
using NSubstitute.ReturnsExtensions;
using NUnit.Framework;
using YPS.Beer.Controllers;
using YPS.Beer.Services;
namespace YPS.Beer.Tests.Controllers;
public class BeerControllerTests
{
[Test]
public async Task GetBeer_ReturnsNotFound_WhenNoBeerFound()
{
var mockService = Substitute.For<IPunkService>();
mockService.GetBeer(1).ReturnsNull();
var controller = new BeerController(mockService);
var result = await controller.GetBeer(1);
Assert.That(result, Is.TypeOf<NotFoundResult>());
}
[Test]
public async Task GetBeer_ReturnsBeer_WhenBeerFound()
{
var mockService = Substitute.For<IPunkService>();
mockService.GetBeer(1).Returns(new Models.Beer
{
Id = 1,
Name = "Some beer!",
});
var controller = new BeerController(mockService);
var result = await controller.GetBeer(1);
var resultObject = result as OkObjectResult;
var resultValue = resultObject?.Value as Models.Beer;
Assert.That(resultValue, Is.Not.Null);
Assert.That(resultValue.Id, Is.EqualTo(1));
Assert.That(resultValue.Name, Is.EqualTo("Some beer!"));
}
[Test]
public async Task SearchBeer_ReturnsEmptyArray_WhenNoBeerFound()
{
var mockService = Substitute.For<IPunkService>();
mockService.FindBeers("asdf").Returns(Array.Empty<Models.Beer>());
var controller = new BeerController(mockService);
var result = await controller.SearchBeer("asdf");
var resultObject = result as OkObjectResult;
var resultValue = resultObject?.Value as Models.Beer[];
Assert.That(resultValue, Is.Not.Null);
Assert.That(resultValue, Is.Empty);
}
[Test]
public async Task SearchBeer_ReturnsBeers_WhenBeersFound()
{
var mockService = Substitute.For<IPunkService>();
mockService.FindBeers("asdf").Returns(new []
{
new Models.Beer
{
Id = 1,
Name = "First beer!",
},
new Models.Beer
{
Id = 14,
Name = "Another beer!",
},
new Models.Beer
{
Id = 45,
Name = "IPA",
},
});
var controller = new BeerController(mockService);
var result = await controller.SearchBeer("asdf");
var resultObject = result as OkObjectResult;
var resultValue = resultObject?.Value as Models.Beer[];
Assert.That(resultValue, Is.Not.Null);
Assert.That(resultValue, Has.Length.EqualTo(3));
}
}

View File

@ -0,0 +1,202 @@
using System.Security.Claims;
using System.Security.Principal;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using NSubstitute;
using NUnit.Framework;
using YPS.Beer.Controllers;
using YPS.Beer.DTOs.Requests;
using YPS.Beer.Models;
using YPS.Beer.Services;
namespace YPS.Beer.Tests.Controllers;
public class FavouritesControllerTests
{
[Test]
public async Task GetFavourites_ReturnsEmptyArray_WhenUserHasNoFavourites()
{
var beerService = Substitute.For<IBeerService>();
var punkService = Substitute.For<IPunkService>();
beerService.GetUserById("123456").Returns(new User
{
Id = "123456",
});
var identity = new GenericIdentity("adf");
identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, "123456"));
var controller = new FavouritesController(beerService, punkService)
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext
{
User = new ClaimsPrincipal(identity),
}
}
};
var result = await controller.GetFavourites();
var resultObject = result as OkObjectResult;
var resultValue = resultObject?.Value as IEnumerable<Models.Beer>;
Assert.That(resultValue, Is.Empty);
}
[Test]
public async Task GetFavourites_ReturnsFavouriteBeers_WhenUserHasFavourites()
{
var beerService = Substitute.For<IBeerService>();
var punkService = Substitute.For<IPunkService>();
beerService.GetUserById("123456").Returns(new User
{
Id = "123456",
Favourites =
{
new Favourite
{
Id = 1,
BeerId = 34,
UserId = "123456",
},
new Favourite
{
Id = 2,
BeerId = 4,
UserId = "123456",
},
new Favourite
{
Id = 3,
BeerId = 56,
UserId = "654321",
}
}
});
punkService.GetBeers(Arg.Is<int[]>(ids => ids[0] == 34 && ids[1] == 4)).Returns(new List<Models.Beer>
{
new()
{
Id = 34,
Name = "This beer!",
},
new()
{
Id = 4,
Name = "Another beer!",
}
});
var identity = new GenericIdentity("adf");
identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, "123456"));
var controller = new FavouritesController(beerService, punkService)
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext
{
User = new ClaimsPrincipal(identity),
}
}
};
var result = await controller.GetFavourites();
var resultObject = result as OkObjectResult;
var resultValue = resultObject?.Value as IEnumerable<Models.Beer>;
var resultList = resultValue?.ToArray();
Assert.That(resultList, Is.Not.Null);
Assert.That(resultList, Has.Length.EqualTo(2));
}
[Test]
public async Task AddFavourite_ReturnsBadRequest_WhenUserAlreadyHasFavourite()
{
var beerService = Substitute.For<IBeerService>();
var punkService = Substitute.For<IPunkService>();
beerService.GetUserById("123456").Returns(new User
{
Id = "123456",
Favourites =
{
new Favourite
{
Id = 1,
BeerId = 34,
UserId = "123456",
},
}
});
var identity = new GenericIdentity("adf");
identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, "123456"));
var controller = new FavouritesController(beerService, punkService)
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext
{
User = new ClaimsPrincipal(identity),
}
}
};
var result = await controller.AddFavourite(new AddFavouriteRequest
{
BeerId = 34,
});
Assert.That(result, Is.TypeOf<BadRequestResult>());
}
[Test]
public async Task AddFavourite_ReturnsCreated_WhenFavouriteAddedToUser()
{
var beerService = Substitute.For<IBeerService>();
var punkService = Substitute.For<IPunkService>();
beerService.GetUserById("123456").Returns(new User
{
Id = "123456",
});
beerService.AddFavouriteToUser("123456", Arg.Is<Favourite>(f => f.BeerId == 34)).Returns(new Favourite
{
Id = 1,
UserId = "123456",
BeerId = 34,
});
var identity = new GenericIdentity("adf");
identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, "123456"));
var controller = new FavouritesController(beerService, punkService)
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext
{
User = new ClaimsPrincipal(identity),
}
}
};
var result = await controller.AddFavourite(new AddFavouriteRequest
{
BeerId = 34,
});
Assert.That(result, Is.TypeOf<CreatedResult>());
await beerService.Received(1).AddFavouriteToUser("123456", Arg.Is<Favourite>(f => f.BeerId == 34));
}
}

View File

@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.6.0"/>
<PackageReference Include="NSubstitute" Version="5.1.0" />
<PackageReference Include="NUnit" Version="3.13.3"/>
<PackageReference Include="NUnit3TestAdapter" Version="4.2.1"/>
<PackageReference Include="NUnit.Analyzers" Version="3.6.1"/>
<PackageReference Include="coverlet.collector" Version="6.0.0"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\YPS.Beer\YPS.Beer.csproj" />
</ItemGroup>
</Project>

28
backend/YPS.Beer.sln Normal file
View File

@ -0,0 +1,28 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "YPS.Beer", "YPS.Beer\YPS.Beer.csproj", "{A7EF451D-0BEE-45A1-9B4A-2932CE799B27}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "YPS.Beer.Tests", "YPS.Beer.Tests\YPS.Beer.Tests.csproj", "{62B70C52-2767-4A00-8E1F-716A9BC7A490}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{A7EF451D-0BEE-45A1-9B4A-2932CE799B27}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A7EF451D-0BEE-45A1-9B4A-2932CE799B27}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A7EF451D-0BEE-45A1-9B4A-2932CE799B27}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A7EF451D-0BEE-45A1-9B4A-2932CE799B27}.Release|Any CPU.Build.0 = Release|Any CPU
{62B70C52-2767-4A00-8E1F-716A9BC7A490}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{62B70C52-2767-4A00-8E1F-716A9BC7A490}.Debug|Any CPU.Build.0 = Debug|Any CPU
{62B70C52-2767-4A00-8E1F-716A9BC7A490}.Release|Any CPU.ActiveCfg = Release|Any CPU
{62B70C52-2767-4A00-8E1F-716A9BC7A490}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal

View File

@ -0,0 +1,16 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Mvc;
namespace YPS.Beer.Controllers;
[ApiController]
public class AuthController : ControllerBase
{
[HttpGet("logout")]
public async Task<IActionResult> Logout()
{
await HttpContext.SignOutAsync();
return Ok();
}
}

View File

@ -0,0 +1,32 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using YPS.Beer.Services;
namespace YPS.Beer.Controllers;
[Authorize]
[ApiController]
[Route("[controller]")]
public class BeerController : ControllerBase
{
private readonly IPunkService _punkService;
public BeerController(IPunkService punkService)
{
_punkService = punkService;
}
[HttpGet("/{id}")]
public async Task<IActionResult> GetBeer(int id)
{
var beer = await _punkService.GetBeer(id);
return beer is null ? NotFound() : Ok(beer);
}
[HttpGet]
public async Task<IActionResult> SearchBeer(string search)
{
return Ok(await _punkService.FindBeers(search));
}
}

View File

@ -0,0 +1,61 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using YPS.Beer.DTOs.Requests;
using YPS.Beer.Models;
using YPS.Beer.Services;
namespace YPS.Beer.Controllers;
[Authorize]
[ApiController]
[Route("[controller]")]
public class FavouritesController : ControllerBase
{
private readonly IBeerService _beerService;
private readonly IPunkService _punkService;
public FavouritesController(IBeerService beerService, IPunkService punkService)
{
_beerService = beerService;
_punkService = punkService;
}
[HttpGet]
public async Task<IActionResult> GetFavourites()
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (userId is null)
return NotFound();
var user = await _beerService.GetUserById(userId);
if (user is null)
return NotFound();
var favourites = await _punkService.GetBeers(user.Favourites.Select(f => f.BeerId).ToArray());
return Ok(favourites);
}
[HttpPost]
public async Task<IActionResult> AddFavourite([FromBody] AddFavouriteRequest request)
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (userId is null)
return NotFound();
var favourite = await _beerService.AddFavouriteToUser(userId, new Favourite
{
BeerId = request.BeerId,
UserId = userId,
});
if (favourite is null)
return BadRequest();
return Created();
}
}

View File

@ -0,0 +1,6 @@
namespace YPS.Beer.DTOs.Requests;
public class AddFavouriteRequest
{
public int BeerId { get; set; }
}

View File

@ -0,0 +1,22 @@
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using YPS.Beer.Models;
namespace YPS.Beer.Data;
public class BeerContext : IdentityDbContext<User>
{
public BeerContext(DbContextOptions<BeerContext> options) : base(options)
{
}
protected override void OnModelCreating(ModelBuilder builder)
{
builder.Entity<User>()
.HasMany(u => u.Favourites)
.WithOne(f => f.User)
.HasForeignKey("UserId");
base.OnModelCreating(builder);
}
}

View File

@ -0,0 +1,304 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using YPS.Beer.Data;
#nullable disable
namespace YPS.Beer.Migrations
{
[DbContext(typeof(BeerContext))]
[Migration("20231206021610_InitialCreate")]
partial class InitialCreate
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.0");
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<string>("RoleId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("ProviderKey")
.HasColumnType("TEXT");
b.Property<string>("ProviderDisplayName")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("TEXT");
b.Property<string>("RoleId")
.HasColumnType("TEXT");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("TEXT");
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasColumnType("TEXT");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("YPS.Beer.Models.Favourite", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("BeerId")
.HasColumnType("INTEGER");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("Favourite");
});
modelBuilder.Entity("YPS.Beer.Models.User", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<int>("AccessFailedCount")
.HasColumnType("INTEGER");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("EmailConfirmed")
.HasColumnType("INTEGER");
b.Property<bool>("LockoutEnabled")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("TEXT");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.HasColumnType("TEXT");
b.Property<string>("PhoneNumber")
.HasColumnType("TEXT");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("INTEGER");
b.Property<string>("SecurityStamp")
.HasColumnType("TEXT");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("INTEGER");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.HasOne("YPS.Beer.Models.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("YPS.Beer.Models.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("YPS.Beer.Models.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.HasOne("YPS.Beer.Models.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("YPS.Beer.Models.Favourite", b =>
{
b.HasOne("YPS.Beer.Models.User", "User")
.WithMany("Favourites")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("YPS.Beer.Models.User", b =>
{
b.Navigation("Favourites");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,250 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace YPS.Beer.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "AspNetRoles",
columns: table => new
{
Id = table.Column<string>(type: "TEXT", nullable: false),
Name = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
NormalizedName = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
ConcurrencyStamp = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetRoles", x => x.Id);
});
migrationBuilder.CreateTable(
name: "AspNetUsers",
columns: table => new
{
Id = table.Column<string>(type: "TEXT", nullable: false),
UserName = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
NormalizedUserName = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
Email = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
NormalizedEmail = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
EmailConfirmed = table.Column<bool>(type: "INTEGER", nullable: false),
PasswordHash = table.Column<string>(type: "TEXT", nullable: true),
SecurityStamp = table.Column<string>(type: "TEXT", nullable: true),
ConcurrencyStamp = table.Column<string>(type: "TEXT", nullable: true),
PhoneNumber = table.Column<string>(type: "TEXT", nullable: true),
PhoneNumberConfirmed = table.Column<bool>(type: "INTEGER", nullable: false),
TwoFactorEnabled = table.Column<bool>(type: "INTEGER", nullable: false),
LockoutEnd = table.Column<DateTimeOffset>(type: "TEXT", nullable: true),
LockoutEnabled = table.Column<bool>(type: "INTEGER", nullable: false),
AccessFailedCount = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUsers", x => x.Id);
});
migrationBuilder.CreateTable(
name: "AspNetRoleClaims",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
RoleId = table.Column<string>(type: "TEXT", nullable: false),
ClaimType = table.Column<string>(type: "TEXT", nullable: true),
ClaimValue = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id);
table.ForeignKey(
name: "FK_AspNetRoleClaims_AspNetRoles_RoleId",
column: x => x.RoleId,
principalTable: "AspNetRoles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserClaims",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
UserId = table.Column<string>(type: "TEXT", nullable: false),
ClaimType = table.Column<string>(type: "TEXT", nullable: true),
ClaimValue = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserClaims", x => x.Id);
table.ForeignKey(
name: "FK_AspNetUserClaims_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserLogins",
columns: table => new
{
LoginProvider = table.Column<string>(type: "TEXT", nullable: false),
ProviderKey = table.Column<string>(type: "TEXT", nullable: false),
ProviderDisplayName = table.Column<string>(type: "TEXT", nullable: true),
UserId = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserLogins", x => new { x.LoginProvider, x.ProviderKey });
table.ForeignKey(
name: "FK_AspNetUserLogins_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserRoles",
columns: table => new
{
UserId = table.Column<string>(type: "TEXT", nullable: false),
RoleId = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId });
table.ForeignKey(
name: "FK_AspNetUserRoles_AspNetRoles_RoleId",
column: x => x.RoleId,
principalTable: "AspNetRoles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_AspNetUserRoles_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserTokens",
columns: table => new
{
UserId = table.Column<string>(type: "TEXT", nullable: false),
LoginProvider = table.Column<string>(type: "TEXT", nullable: false),
Name = table.Column<string>(type: "TEXT", nullable: false),
Value = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name });
table.ForeignKey(
name: "FK_AspNetUserTokens_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Favourite",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
UserId = table.Column<string>(type: "TEXT", nullable: false),
BeerId = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Favourite", x => x.Id);
table.ForeignKey(
name: "FK_Favourite_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_AspNetRoleClaims_RoleId",
table: "AspNetRoleClaims",
column: "RoleId");
migrationBuilder.CreateIndex(
name: "RoleNameIndex",
table: "AspNetRoles",
column: "NormalizedName",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_AspNetUserClaims_UserId",
table: "AspNetUserClaims",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_AspNetUserLogins_UserId",
table: "AspNetUserLogins",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_AspNetUserRoles_RoleId",
table: "AspNetUserRoles",
column: "RoleId");
migrationBuilder.CreateIndex(
name: "EmailIndex",
table: "AspNetUsers",
column: "NormalizedEmail");
migrationBuilder.CreateIndex(
name: "UserNameIndex",
table: "AspNetUsers",
column: "NormalizedUserName",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Favourite_UserId",
table: "Favourite",
column: "UserId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AspNetRoleClaims");
migrationBuilder.DropTable(
name: "AspNetUserClaims");
migrationBuilder.DropTable(
name: "AspNetUserLogins");
migrationBuilder.DropTable(
name: "AspNetUserRoles");
migrationBuilder.DropTable(
name: "AspNetUserTokens");
migrationBuilder.DropTable(
name: "Favourite");
migrationBuilder.DropTable(
name: "AspNetRoles");
migrationBuilder.DropTable(
name: "AspNetUsers");
}
}
}

View File

@ -0,0 +1,301 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using YPS.Beer.Data;
#nullable disable
namespace YPS.Beer.Migrations
{
[DbContext(typeof(BeerContext))]
partial class BeerContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.0");
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<string>("RoleId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("ProviderKey")
.HasColumnType("TEXT");
b.Property<string>("ProviderDisplayName")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("TEXT");
b.Property<string>("RoleId")
.HasColumnType("TEXT");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("TEXT");
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasColumnType("TEXT");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("YPS.Beer.Models.Favourite", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("BeerId")
.HasColumnType("INTEGER");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("Favourite");
});
modelBuilder.Entity("YPS.Beer.Models.User", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<int>("AccessFailedCount")
.HasColumnType("INTEGER");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("EmailConfirmed")
.HasColumnType("INTEGER");
b.Property<bool>("LockoutEnabled")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("TEXT");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.HasColumnType("TEXT");
b.Property<string>("PhoneNumber")
.HasColumnType("TEXT");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("INTEGER");
b.Property<string>("SecurityStamp")
.HasColumnType("TEXT");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("INTEGER");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.HasOne("YPS.Beer.Models.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("YPS.Beer.Models.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("YPS.Beer.Models.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.HasOne("YPS.Beer.Models.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("YPS.Beer.Models.Favourite", b =>
{
b.HasOne("YPS.Beer.Models.User", "User")
.WithMany("Favourites")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("YPS.Beer.Models.User", b =>
{
b.Navigation("Favourites");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,22 @@
using System.Text.Json.Serialization;
namespace YPS.Beer.Models;
public class Beer
{
public int Id { get; set; }
public string Name { get; set; } = null!;
public string Tagline { get; set; } = null!;
[JsonPropertyName("first_brewed")]
public string FirstBrewed { get; set; } = null!;
public string Description { get; set; } = null!;
[JsonPropertyName("image_url")]
public string ImageUrl { get; set; } = null!;
public float Abv { get; set; }
public float Iby { get; set; }
[JsonPropertyName("food_pairing")]
public string[] FoodPairing { get; set; } = null!;
}

View File

@ -0,0 +1,9 @@
namespace YPS.Beer.Models;
public class Favourite
{
public int Id { get; set; }
public string UserId { get; set; } = null!;
public User User { get; set; } = null!;
public int BeerId { get; set; }
}

View File

@ -0,0 +1,8 @@
using Microsoft.AspNetCore.Identity;
namespace YPS.Beer.Models;
public class User : IdentityUser
{
public ICollection<Favourite> Favourites { get; } = new List<Favourite>();
}

View File

@ -0,0 +1,44 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using YPS.Beer.Data;
using YPS.Beer.Models;
using YPS.Beer.Services;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddControllers();
builder.Services.AddHttpClient<IPunkService, PunkService>();
builder.Services.AddScoped<IBeerService, BeerService>();
builder.Services.AddDbContext<BeerContext>(options => options.UseInMemoryDatabase("yps-beer"));
builder.Services.AddIdentityCore<User>()
.AddEntityFrameworkStores<BeerContext>()
.AddApiEndpoints();
builder.Services.AddAuthentication(IdentityConstants.ApplicationScheme)
.AddBearerToken(IdentityConstants.BearerScheme)
.AddApplicationCookie();
builder.Services.AddAuthorizationBuilder();
builder.Services.AddCors();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
app.UseCors(cors => cors.AllowAnyHeader().AllowAnyMethod().SetIsOriginAllowed(_ => true).AllowCredentials());
}
app.UseHttpsRedirection();
app.MapControllers();
app.MapIdentityApi<User>();
app.Run();

View File

@ -0,0 +1,41 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:21894",
"sslPort": 44357
}
},
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "swagger",
"applicationUrl": "http://localhost:5279",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "swagger",
"applicationUrl": "https://localhost:7084;http://localhost:5279",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"launchUrl": "swagger",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@ -0,0 +1,37 @@
using Microsoft.EntityFrameworkCore;
using YPS.Beer.Data;
using YPS.Beer.Models;
namespace YPS.Beer.Services;
public class BeerService : IBeerService
{
private readonly BeerContext _context;
public BeerService(BeerContext context)
{
_context = context;
}
public async Task<User?> GetUserById(string userId) =>
await _context.Users
.Include(u => u.Favourites)
.SingleOrDefaultAsync(u => u.Id == userId);
public async Task<Favourite?> AddFavouriteToUser(string userId, Favourite favourite)
{
var user = await GetUserById(userId);
if (user is null)
return null;
if (user.Favourites.Any(f => f.BeerId == favourite.BeerId))
return null;
user.Favourites.Add(favourite);
await _context.SaveChangesAsync();
return favourite;
}
}

View File

@ -0,0 +1,9 @@
using YPS.Beer.Models;
namespace YPS.Beer.Services;
public interface IBeerService
{
public Task<User?> GetUserById(string id);
public Task<Favourite?> AddFavouriteToUser(string userId, Favourite favourite);
}

View File

@ -0,0 +1,8 @@
namespace YPS.Beer.Services;
public interface IPunkService
{
public Task<Models.Beer?> GetBeer(int id);
public Task<IEnumerable<Models.Beer>> GetBeers(params int[] id);
public Task<IEnumerable<Models.Beer>> FindBeers(string search);
}

View File

@ -0,0 +1,47 @@
namespace YPS.Beer.Services;
public class PunkService : IPunkService
{
private readonly HttpClient _httpClient;
private const string BaseUrl = "https://api.punkapi.com/v2/";
public PunkService(HttpClient httpClient)
{
_httpClient = httpClient;
}
public async Task<Models.Beer?> GetBeer(int id)
{
var beer = await _httpClient.GetFromJsonAsync<Models.Beer[]>($"{BaseUrl}beers/{id}");
return beer?.SingleOrDefault();
}
public async Task<IEnumerable<Models.Beer>> GetBeers(params int[] ids)
{
var beers = new List<Models.Beer>();
foreach (var id in ids)
{
var beer = await GetBeer(id);
if (beer is not null)
beers.Add(beer);
}
return beers;
}
public async Task<IEnumerable<Models.Beer>> FindBeers(string search)
{
search = search.Replace(' ', '_');
var beers = await _httpClient.GetFromJsonAsync<Models.Beer[]>($"{BaseUrl}beers?beer_name={search}");
if (beers is null)
throw new Exception();
return beers;
}
}

View File

@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.4.0" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="YPS.Beer.Tests"/>
</ItemGroup>
</Project>

View File

@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

4
frontend/.browserslistrc Normal file
View File

@ -0,0 +1,4 @@
> 1%
last 2 versions
not dead
not ie 11

5
frontend/.editorconfig Normal file
View File

@ -0,0 +1,5 @@
[*.{js,jsx,ts,tsx,vue}]
indent_style = space
indent_size = 2
trim_trailing_whitespace = true
insert_final_newline = true

14
frontend/.eslintrc.js Normal file
View File

@ -0,0 +1,14 @@
module.exports = {
root: true,
env: {
node: true,
},
extends: [
'plugin:vue/vue3-essential',
'eslint:recommended',
'@vue/eslint-config-typescript',
],
rules: {
'vue/multi-word-component-names': 'off',
},
}

22
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,22 @@
.DS_Store
node_modules
/dist
# local env files
.env.local
.env.*.local
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

48
frontend/README.md Normal file
View File

@ -0,0 +1,48 @@
# YPM Beer (frontend)
A super-simple frontend for viewing and searching Beers from the [PunkAPI](https://punkapi.com/).
## Setup
### Requirements
To run in development mode, you will need:
* [node](https://nodejs.org/en) (v20>=)
### Installing dependencies
To install the node dependencies required, run:
```sh
npm i
```
### Running locally
To run in development mode, run:
```sh
npm run dev
```
The application should be accessable at:
```sh
http://localhost:3000
```
### Configuring
The app should already be configured to connect to the backend once it is running, but the URL can be modified at `src/utils/configuration.ts`.
## Technology
To create this project, the following was used:
* Node 20
* Vite & Vitest
* Vue3 (with composition API & TypeScript & VueRouter)
* Vuetify for the UI framework
* Vue-Query (Tanstack Query) for communicating with the backend.
## To improve:
### UI
The UI isn't the best at all, it's somewhat responsive but data display should be a lot better.
## Testing
If I had more time, I would have loved to use `Testing-Library` to add some tests for the frontend.
## Auth Handling
Right now, it uses very simple cookie auth - which can be a little hard to manage.

16
frontend/index.html Normal file
View File

@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>YPS Beer</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

4608
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

36
frontend/package.json Normal file
View File

@ -0,0 +1,36 @@
{
"name": "yps-beer",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview",
"lint": "eslint . --fix --ignore-path .gitignore",
"test": "vitest"
},
"dependencies": {
"@mdi/font": "7.0.96",
"@tanstack/vue-query": "^5.12.2",
"core-js": "^3.29.0",
"roboto-fontface": "*",
"vue": "^3.2.0",
"vue-router": "^4.0.0",
"vuetify": "^3.0.0"
},
"devDependencies": {
"@babel/types": "^7.21.4",
"@testing-library/vue": "^8.0.1",
"@types/node": "^18.15.0",
"@vitejs/plugin-vue": "^4.0.0",
"@vue/eslint-config-typescript": "^11.0.0",
"eslint": "^8.22.0",
"eslint-plugin-vue": "^9.3.0",
"sass": "^1.60.0",
"typescript": "^5.0.0",
"unplugin-fonts": "^1.0.3",
"vite": "^4.2.0",
"vite-plugin-vuetify": "^1.0.0",
"vue-tsc": "^1.2.0"
}
}

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

6
frontend/src/App.vue Normal file
View File

@ -0,0 +1,6 @@
<template>
<router-view />
</template>
<script lang="ts" setup>
</script>

View File

@ -0,0 +1,87 @@
<script lang="ts" setup>
import { ref } from 'vue';
import { Auth } from '../../connectors/auth.connector';
import router from '../../router';
const email = ref('');
const password = ref('');
const loginFailure = ref<boolean>(false);
async function login() {
const success = await Auth.Login(email.value, password.value);
if (success) {
router.push('/search');
return;
}
loginFailure.value = true;
}
</script>
<template>
<v-form @submit.prevent="login">
<v-card
class="mx-auto pa-12 pb-8"
elevation="8"
max-width="448"
rounded="lg"
>
<div class="text-subtitle-1 text-medium-emphasis">Account</div>
<v-text-field
density="compact"
placeholder="Email address"
prepend-inner-icon="mdi-email-outline"
variant="outlined"
v-model="email"
:error="loginFailure"
@update:model-value="() => loginFailure = false"
></v-text-field>
<div class="text-subtitle-1 text-medium-emphasis d-flex align-center justify-space-between">
Password
</div>
<v-text-field
type="password"
density="compact"
placeholder="Enter your password"
prepend-inner-icon="mdi-lock-outline"
variant="outlined"
v-model="password"
:error="loginFailure"
@update:model-value="() => loginFailure = false"
></v-text-field>
<v-label
v-if="loginFailure"
class="text-red">
Login failed, please try again.
</v-label>
<v-btn
block
class="mb-8"
color="blue"
size="large"
variant="tonal"
type="submit"
>
Log In
</v-btn>
<v-card-text class="text-center">
<a
class="text-blue text-decoration-none"
href="/register"
rel="noopener noreferrer"
>
Sign up now <v-icon icon="mdi-chevron-right"></v-icon>
</a>
</v-card-text>
</v-card>
</v-form>
</template>

View File

@ -0,0 +1,90 @@
<script lang="ts" setup>
import { ref } from 'vue';
import { Auth } from '../../connectors/auth.connector';
import router from '../../router';
const email = ref('');
const password = ref('');
const confirmPassword = ref('');
const errors = ref<string[]>([]);
async function register() {
if (password.value !== confirmPassword.value) {
return;
}
const result = await Auth.Register(email.value, password.value);
if (result.success) {
router.push('/login');
return;
}
errors.value = Object.values(result.errors ?? []).flat();
}
</script>
<template>
<v-form @submit.prevent="register">
<v-card
class="mx-auto pa-12 pb-8"
elevation="8"
max-width="448"
rounded="lg"
>
<div class="text-subtitle-1 text-medium-emphasis">Account</div>
<v-text-field
density="compact"
placeholder="Email address"
prepend-inner-icon="mdi-email-outline"
variant="outlined"
v-model="email"
:error="errors.length > 0"
></v-text-field>
<div class="text-subtitle-1 text-medium-emphasis d-flex align-center justify-space-between">
Password
</div>
<v-text-field
type="password"
density="compact"
placeholder="Enter your password"
prepend-inner-icon="mdi-lock-outline"
variant="outlined"
v-model="password"
:error="errors.length > 0"
></v-text-field>
<v-text-field
type="password"
density="compact"
placeholder="Re-Enter your password"
prepend-inner-icon="mdi-lock-outline"
variant="outlined"
v-model="confirmPassword"
:error="errors.length > 0"
></v-text-field>
<v-label
v-if="errors"
class="text-red">
{{ errors.join('\n') }}
</v-label>
<v-btn
block
class="mb-8"
color="blue"
size="large"
variant="tonal"
type="submit"
>
Sign Up
</v-btn>
</v-card>
</v-form>
</template>

View File

@ -0,0 +1,30 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { Debounce } from '@/utils/debounce';
type Emits = {
(e: 'onSearch', value: string): void,
};
const emit = defineEmits<Emits>();
const searchContent = ref('');
const debounce = new Debounce();
watch(searchContent, () => debounce.debounce(() => emit('onSearch', searchContent.value), 200));
</script>
<template>
<v-text-field
variant="solo"
label="Search beer"
append-inner-icon="mdi-magnify"
single-line
hide-details
v-model="searchContent"
/>
</template>
<style scoped>
</style>

View File

@ -0,0 +1,33 @@
<script lang="ts" setup>
import SearchField from '@/components/Inputs/SearchField.vue';
import { ref } from 'vue';
import { searchBeer } from '@/connectors/punk.connector';
import SearchTable from '@/components/Tables/SearchTable.vue';
import { useQuery } from '@tanstack/vue-query';
const searchValue = ref<string>('');
const { isLoading, error, data } = useQuery({
queryKey: ['search', searchValue],
queryFn: async () => {
if (!searchValue.value) {
return [];
}
return await searchBeer(searchValue.value)
},
});
</script>
<template>
<v-container class="fill-height">
<v-responsive class="align-center text-center fill-height">
<search-field @on-search="async (value: string) => searchValue = value" />
<search-table
:beers="data ?? []"
:error="error?.message"
:loading="isLoading"
/>
</v-responsive>
</v-container>
</template>

View File

@ -0,0 +1,41 @@
<script setup lang="ts">
import { Beer } from '@/models/Beer';
type Props = {
beers: Beer[],
loading: boolean,
error?: string,
};
const { beers } = defineProps<Props>();
</script>
<template>
<h1>Favourites</h1>
<p v-if="loading">Loading...</p>
<p v-else-if="error">An error has occurred, please try refreshing the page.</p>
<v-table v-else>
<thead class="text-center">
<tr>
<th>
ID
</th>
<th>
NAME
</th>
</tr>
</thead>
<tbody>
<tr
v-for="beer in beers"
:key="beer.id">
<td>{{ beer.id }}</td>
<td>{{ beer.name }}</td>
</tr>
</tbody>
</v-table>
</template>
<style scoped>
</style>

View File

@ -0,0 +1,57 @@
<script setup lang="ts">
import { Beer } from '@/models/Beer';
import { addFavouriteBeer } from '../../connectors/punk.connector';
import { ref } from 'vue';
type Props = {
beers: Beer[],
loading: boolean,
error?: string,
};
const snackbar = ref(false);
const snackbarText = ref('');
const { beers } = defineProps<Props>();
</script>
<template>
<v-snackbar
v-model="snackbar"
>
{{ snackbarText }}
</v-snackbar>
<p v-if="loading">Loading...</p>
<p v-else-if="error">An error has occurred. {{ error }}</p>
<div
v-else
@click="async () => {
const result = await addFavouriteBeer(beer.id);
snackbar = true;
snackbarText = result ? `Added ${beer.name} to favourites!` : 'An error occurred whilst trying to add a favourite.';
}"
class="mt-2 mb-4 entry rounded-xl"
v-for="beer in beers"
:key="beer.id">
<p>
{{ beer.name }} -
{{ beer.id }}
</p>
{{ beer.tagline }}
{{ beer.first_brewed }}
{{ beer.description }}
{{ beer.image_url }}
{{ beer.abv }}
{{ beer.iby }}
{{ beer.food_pairing?.join(',') }}
</div>
</template>
<style scoped>
.entry {
background-color:azure;
cursor: pointer;
}
</style>

View File

@ -0,0 +1,69 @@
import { User } from "../models/User";
import { Configuration } from '@/utils/configuration';
type RegisterResponse = {
success: boolean,
errors?: Record<string, string[]>;
};
export namespace Auth {
export async function Login(email: string, password: string): Promise<boolean> {
const response = await fetch(`${Configuration.APIBaseUrl}/login?useCookies=true`, {
method: 'POST',
credentials: 'include',
body: JSON.stringify({
email,
password
}),
headers: {
'Content-Type': 'application/json',
},
});
return response.ok;
}
export async function Register(email: string, password: string): Promise<RegisterResponse> {
const response = await fetch(`${Configuration.APIBaseUrl}/register`, {
method: 'POST',
credentials: 'include',
body: JSON.stringify({
email,
password
}),
headers: {
'Content-Type': 'application/json',
},
});
if (response.status === 400) {
return {
success: false,
errors: (await response.json()).errors,
};
}
return {
success: response.ok,
};
}
export async function Logout() {
await fetch(`${Configuration.APIBaseUrl}/logout`, {
method: 'GET',
credentials: 'include',
});
}
export async function Me(): Promise<User | null> {
const response = await fetch(`${Configuration.APIBaseUrl}/manage/info`, {
credentials: 'include',
});
if (!response.ok) {
return null;
}
return await response.json();
}
}

View File

@ -0,0 +1,34 @@
import { Beer } from '@/models/Beer';
import { Configuration } from '@/utils/configuration';
export async function searchBeer(search: string): Promise<Beer[]> {
const response = await fetch(`${Configuration.APIBaseUrl}/Beer?search=${search}`, {
credentials: 'include',
});
return await response.json();
}
export async function addFavouriteBeer(beerId: number): Promise<boolean> {
const response = await fetch(`${Configuration.APIBaseUrl}/Favourites`, {
method: 'POST',
body: JSON.stringify({
beerId,
}),
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
});
return response.ok;
}
export async function getFavouriteBeers(): Promise<Beer[]> {
const response = await fetch(`${Configuration.APIBaseUrl}/Favourites`, {
credentials: 'include',
});
return await response.json();
}

View File

@ -0,0 +1,33 @@
<script lang="ts" setup>
import { Auth } from '../../connectors/auth.connector';
import router from '../../router';
import { useQuery } from '@tanstack/vue-query';
const logout = async () => {
await Auth.Logout();
await router.push('/login');
};
const { isLoading, data: user } = useQuery({
queryKey: ['user'],
queryFn: () => Auth.Me(),
});
</script>
<template>
<v-app-bar>
<v-app-bar-title>
<v-icon icon="mdi-glass-mug" />
Beer Pair
</v-app-bar-title>
<router-link to="/search" class="pr-2">
Search
</router-link>
<router-link to="/favourites" class="pr-2">
Favourites
</router-link>
<p v-if="isLoading">...</p>
<p v-else class="pr-2">{{ user?.email }}</p>
<button @click="async () => await logout()">Logout</button>
</v-app-bar>
</template>

View File

@ -0,0 +1,12 @@
<template>
<v-app>
<default-bar />
<default-view />
</v-app>
</template>
<script lang="ts" setup>
import DefaultBar from './AppBar.vue';
import DefaultView from './View.vue';
</script>

View File

@ -0,0 +1,8 @@
<template>
<v-main>
<router-view />
</v-main>
</template>
<script lang="ts" setup>
</script>

11
frontend/src/main.ts Normal file
View File

@ -0,0 +1,11 @@
import { registerPlugins } from '@/plugins';
import App from './App.vue';
import { createApp } from 'vue';
const app = createApp(App);
registerPlugins(app);
app.mount('#app');

View File

@ -0,0 +1,11 @@
export type Beer = {
id: number;
name: string;
tagline: string;
first_brewed: string;
description: string;
image_url: string;
abv: number;
iby: number;
food_pairing: string[];
}

View File

@ -0,0 +1,3 @@
export type User = {
email: string;
};

View File

@ -0,0 +1,20 @@
/**
* plugins/index.ts
*
* Automatically included in `./src/main.ts`
*/
// Plugins
import vuetify from './vuetify'
import router from '../router'
// Types
import type { App } from 'vue';
import { VueQueryPlugin } from '@tanstack/vue-query';
export function registerPlugins (app: App) {
app
.use(vuetify)
.use(router)
.use(VueQueryPlugin);
}

View File

@ -0,0 +1,18 @@
import '@mdi/font/css/materialdesignicons.css';
import 'vuetify/styles';
import { createVuetify } from 'vuetify';
// https://vuetifyjs.com/en/introduction/why-vuetify/#feature-guides
export default createVuetify({
theme: {
themes: {
light: {
colors: {
primary: '#1867C0',
secondary: '#5CBBF6',
},
},
},
},
});

View File

@ -0,0 +1,90 @@
import { createRouter, createWebHistory } from 'vue-router';
import { Auth } from '../connectors/auth.connector';
const routes = [
{
path: '/',
compoent: () => import('@/layouts/default/Default.vue'),
children: [
{
path: '/',
name: '',
component: () => import('@/views/Login.vue'),
}
],
},
{
path: '/login',
compoent: () => import('@/layouts/default/Default.vue'),
children: [
{
path: '/login',
name: 'Login',
component: () => import('@/views/Login.vue'),
}
],
},
{
path: '/register',
compoent: () => import('@/layouts/default/Default.vue'),
children: [
{
path: '/register',
name: 'Register',
component: () => import('@/views/Register.vue'),
}
],
},
{
path: '/search',
component: () => import('@/layouts/default/Default.vue'),
children: [
{
path: '/search',
name: 'Search',
// route level code-splitting
// this generates a separate chunk (Home-[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import('@/views/Search.vue'),
},
],
},
{
path: '/favourites',
component: () => import('@/layouts/default/Default.vue'),
children: [
{
path: '/favourites',
name: 'Favourites',
// route level code-splitting
// this generates a separate chunk (Home-[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import('@/views/Favourites.vue'),
},
],
},
];
const router = createRouter({
history: createWebHistory(process.env.BASE_URL),
routes,
});
router.beforeEach(async (to) => {
const me = await Auth.Me();
if (!me && to.name !== 'Login' && to.name !== 'Register') {
return { name: 'Login' };
}
if (me && to.name === 'Login') {
console.log('should return to search');
return { name: 'Search' };
}
if (me && to.name === 'Register') {
return { name: 'Search' };
}
});
export default router;

View File

@ -0,0 +1,10 @@
/**
* src/styles/settings.scss
*
* Configures SASS variables and Vuetify overwrites
*/
// https://vuetifyjs.com/features/sass-variables/`
// @use 'vuetify/settings' with (
// $color-pack: false
// );

View File

@ -0,0 +1,3 @@
export const Configuration = {
APIBaseUrl: 'http://localhost:5279',
} as const;

View File

@ -0,0 +1,21 @@
import { describe, expect, it, vi } from 'vitest';
import { Debounce } from './debounce';
describe('debounce', () => {
it('cancels existing callback if a new one is created', () => {
vi.useFakeTimers();
const mockFn = vi.fn();
const debounce = new Debounce();
debounce.debounce(() => mockFn(), 500);
debounce.debounce(() => mockFn(), 500);
debounce.debounce(() => mockFn(), 500);
debounce.debounce(() => mockFn(), 500);
debounce.debounce(() => mockFn(), 500);
vi.advanceTimersByTime(1000);
expect(mockFn).toBeCalledTimes(1);
});
});

View File

@ -0,0 +1,8 @@
export class Debounce {
private timeoutRef?: NodeJS.Timeout;
public debounce(cb: () => void, timeout: number) {
clearTimeout(this.timeoutRef);
this.timeoutRef = setTimeout(cb, timeout);
}
}

View File

@ -0,0 +1,19 @@
<script lang="ts" setup>
import FavouritesTable from '../components/Tables/FavouritesTable.vue';
import { getFavouriteBeers } from '../connectors/punk.connector';
import { useQuery } from '@tanstack/vue-query';
const { isFetching, data, error } = useQuery({
queryKey: ['favourites'],
queryFn: getFavouriteBeers,
});
</script>
<template>
<FavouritesTable
:beers="data ?? []"
:error="error?.message"
:loading="isFetching"
/>
</template>

View File

@ -0,0 +1,8 @@
<script lang="ts" setup>
import LoginForm from '../components/Forms/LoginForm.vue';
</script>
<template>
<LoginForm />
</template>

View File

@ -0,0 +1,7 @@
<script lang="ts" setup>
import RegisterForm from '../components/Forms/RegisterForm.vue';
</script>
<template>
<RegisterForm />
</template>

View File

@ -0,0 +1,7 @@
<template>
<Search />
</template>
<script lang="ts" setup>
import Search from '@/components/Search.vue';
</script>

7
frontend/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}

25
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,25 @@
{
"compilerOptions": {
"baseUrl": ".",
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "Node",
"strict": true,
"jsx": "preserve",
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": ["ESNext", "DOM"],
"skipLibCheck": true,
"noEmit": true,
"paths": {
"@/*": [
"src/*"
]
}
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }],
"exclude": ["node_modules"]
}

View File

@ -0,0 +1,9 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Node",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

44
frontend/vite.config.ts Normal file
View File

@ -0,0 +1,44 @@
// Plugins
import vue from '@vitejs/plugin-vue'
import vuetify, { transformAssetUrls } from 'vite-plugin-vuetify'
import ViteFonts from 'unplugin-fonts/vite'
// Utilities
import { defineConfig } from 'vite'
import { fileURLToPath, URL } from 'node:url'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
vue({
template: { transformAssetUrls },
}),
// https://github.com/vuetifyjs/vuetify-loader/tree/master/packages/vite-plugin#readme
vuetify({
autoImport: true,
styles: {
configFile: 'src/styles/settings.scss',
},
}),
ViteFonts({
google: {
families: [
{
name: 'Roboto',
styles: 'wght@100;300;400;500;700;900',
},
],
},
}),
],
define: { 'process.env': {} },
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
extensions: ['.js', '.json', '.jsx', '.mjs', '.ts', '.tsx', '.vue'],
},
server: {
port: 3000,
},
})