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