Initial commit

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

37
backend/README.md Normal file
View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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