Initial commit
This commit is contained in:
commit
284a36412d
353
.gitignore
vendored
Normal file
353
.gitignore
vendored
Normal 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
16
README.md
Normal 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
37
backend/README.md
Normal 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.
|
94
backend/YPS.Beer.Tests/Controllers/BeerControllerTests.cs
Normal file
94
backend/YPS.Beer.Tests/Controllers/BeerControllerTests.cs
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
202
backend/YPS.Beer.Tests/Controllers/FavouritesControllerTests.cs
Normal file
202
backend/YPS.Beer.Tests/Controllers/FavouritesControllerTests.cs
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
26
backend/YPS.Beer.Tests/YPS.Beer.Tests.csproj
Normal file
26
backend/YPS.Beer.Tests/YPS.Beer.Tests.csproj
Normal 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
28
backend/YPS.Beer.sln
Normal 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
|
16
backend/YPS.Beer/Controllers/AuthController.cs
Normal file
16
backend/YPS.Beer/Controllers/AuthController.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
32
backend/YPS.Beer/Controllers/BeerController.cs
Normal file
32
backend/YPS.Beer/Controllers/BeerController.cs
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
61
backend/YPS.Beer/Controllers/FavouritesController.cs
Normal file
61
backend/YPS.Beer/Controllers/FavouritesController.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
6
backend/YPS.Beer/DTOs/Requests/AddFavouriteRequest.cs
Normal file
6
backend/YPS.Beer/DTOs/Requests/AddFavouriteRequest.cs
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
namespace YPS.Beer.DTOs.Requests;
|
||||||
|
|
||||||
|
public class AddFavouriteRequest
|
||||||
|
{
|
||||||
|
public int BeerId { get; set; }
|
||||||
|
}
|
22
backend/YPS.Beer/Data/BeerContext.cs
Normal file
22
backend/YPS.Beer/Data/BeerContext.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
304
backend/YPS.Beer/Migrations/20231206021610_InitialCreate.Designer.cs
generated
Normal file
304
backend/YPS.Beer/Migrations/20231206021610_InitialCreate.Designer.cs
generated
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
250
backend/YPS.Beer/Migrations/20231206021610_InitialCreate.cs
Normal file
250
backend/YPS.Beer/Migrations/20231206021610_InitialCreate.cs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
301
backend/YPS.Beer/Migrations/BeerContextModelSnapshot.cs
Normal file
301
backend/YPS.Beer/Migrations/BeerContextModelSnapshot.cs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
22
backend/YPS.Beer/Models/Beer.cs
Normal file
22
backend/YPS.Beer/Models/Beer.cs
Normal 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!;
|
||||||
|
}
|
9
backend/YPS.Beer/Models/Favourite.cs
Normal file
9
backend/YPS.Beer/Models/Favourite.cs
Normal 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; }
|
||||||
|
}
|
8
backend/YPS.Beer/Models/User.cs
Normal file
8
backend/YPS.Beer/Models/User.cs
Normal 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>();
|
||||||
|
}
|
44
backend/YPS.Beer/Program.cs
Normal file
44
backend/YPS.Beer/Program.cs
Normal 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();
|
41
backend/YPS.Beer/Properties/launchSettings.json
Normal file
41
backend/YPS.Beer/Properties/launchSettings.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
37
backend/YPS.Beer/Services/BeerService.cs
Normal file
37
backend/YPS.Beer/Services/BeerService.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
9
backend/YPS.Beer/Services/IBeerService.cs
Normal file
9
backend/YPS.Beer/Services/IBeerService.cs
Normal 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);
|
||||||
|
}
|
8
backend/YPS.Beer/Services/IPunkService.cs
Normal file
8
backend/YPS.Beer/Services/IPunkService.cs
Normal 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);
|
||||||
|
}
|
47
backend/YPS.Beer/Services/PunkService.cs
Normal file
47
backend/YPS.Beer/Services/PunkService.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
27
backend/YPS.Beer/YPS.Beer.csproj
Normal file
27
backend/YPS.Beer/YPS.Beer.csproj
Normal 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>
|
8
backend/YPS.Beer/appsettings.Development.json
Normal file
8
backend/YPS.Beer/appsettings.Development.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
9
backend/YPS.Beer/appsettings.json
Normal file
9
backend/YPS.Beer/appsettings.json
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*"
|
||||||
|
}
|
4
frontend/.browserslistrc
Normal file
4
frontend/.browserslistrc
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
> 1%
|
||||||
|
last 2 versions
|
||||||
|
not dead
|
||||||
|
not ie 11
|
5
frontend/.editorconfig
Normal file
5
frontend/.editorconfig
Normal 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
14
frontend/.eslintrc.js
Normal 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
22
frontend/.gitignore
vendored
Normal 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
48
frontend/README.md
Normal 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
16
frontend/index.html
Normal 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
4608
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
frontend/package.json
Normal file
36
frontend/package.json
Normal 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
BIN
frontend/public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 15 KiB |
6
frontend/src/App.vue
Normal file
6
frontend/src/App.vue
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<template>
|
||||||
|
<router-view />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
</script>
|
87
frontend/src/components/Forms/LoginForm.vue
Normal file
87
frontend/src/components/Forms/LoginForm.vue
Normal 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>
|
90
frontend/src/components/Forms/RegisterForm.vue
Normal file
90
frontend/src/components/Forms/RegisterForm.vue
Normal 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>
|
30
frontend/src/components/Inputs/SearchField.vue
Normal file
30
frontend/src/components/Inputs/SearchField.vue
Normal 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>
|
33
frontend/src/components/Search.vue
Normal file
33
frontend/src/components/Search.vue
Normal 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>
|
41
frontend/src/components/Tables/FavouritesTable.vue
Normal file
41
frontend/src/components/Tables/FavouritesTable.vue
Normal 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>
|
57
frontend/src/components/Tables/SearchTable.vue
Normal file
57
frontend/src/components/Tables/SearchTable.vue
Normal 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>
|
69
frontend/src/connectors/auth.connector.ts
Normal file
69
frontend/src/connectors/auth.connector.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
34
frontend/src/connectors/punk.connector.ts
Normal file
34
frontend/src/connectors/punk.connector.ts
Normal 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();
|
||||||
|
}
|
33
frontend/src/layouts/default/AppBar.vue
Normal file
33
frontend/src/layouts/default/AppBar.vue
Normal 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>
|
12
frontend/src/layouts/default/Default.vue
Normal file
12
frontend/src/layouts/default/Default.vue
Normal 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>
|
8
frontend/src/layouts/default/View.vue
Normal file
8
frontend/src/layouts/default/View.vue
Normal 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
11
frontend/src/main.ts
Normal 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');
|
11
frontend/src/models/Beer.ts
Normal file
11
frontend/src/models/Beer.ts
Normal 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[];
|
||||||
|
}
|
3
frontend/src/models/User.ts
Normal file
3
frontend/src/models/User.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export type User = {
|
||||||
|
email: string;
|
||||||
|
};
|
20
frontend/src/plugins/index.ts
Normal file
20
frontend/src/plugins/index.ts
Normal 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);
|
||||||
|
}
|
18
frontend/src/plugins/vuetify.ts
Normal file
18
frontend/src/plugins/vuetify.ts
Normal 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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
90
frontend/src/router/index.ts
Normal file
90
frontend/src/router/index.ts
Normal 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;
|
10
frontend/src/styles/settings.scss
Normal file
10
frontend/src/styles/settings.scss
Normal 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
|
||||||
|
// );
|
3
frontend/src/utils/configuration.ts
Normal file
3
frontend/src/utils/configuration.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export const Configuration = {
|
||||||
|
APIBaseUrl: 'http://localhost:5279',
|
||||||
|
} as const;
|
21
frontend/src/utils/debounce.test.ts
Normal file
21
frontend/src/utils/debounce.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
8
frontend/src/utils/debounce.ts
Normal file
8
frontend/src/utils/debounce.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
19
frontend/src/views/Favourites.vue
Normal file
19
frontend/src/views/Favourites.vue
Normal 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>
|
8
frontend/src/views/Login.vue
Normal file
8
frontend/src/views/Login.vue
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import LoginForm from '../components/Forms/LoginForm.vue';
|
||||||
|
|
||||||
|
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<LoginForm />
|
||||||
|
</template>
|
7
frontend/src/views/Register.vue
Normal file
7
frontend/src/views/Register.vue
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import RegisterForm from '../components/Forms/RegisterForm.vue';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<RegisterForm />
|
||||||
|
</template>
|
7
frontend/src/views/Search.vue
Normal file
7
frontend/src/views/Search.vue
Normal 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
7
frontend/src/vite-env.d.ts
vendored
Normal 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
25
frontend/tsconfig.json
Normal 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"]
|
||||||
|
}
|
9
frontend/tsconfig.node.json
Normal file
9
frontend/tsconfig.node.json
Normal 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
44
frontend/vite.config.ts
Normal 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,
|
||||||
|
},
|
||||||
|
})
|
Loading…
Reference in New Issue
Block a user