diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..294268c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +Dockerfile +docker-compose.yaml +[b|B]in +[O|o]bj diff --git a/.gitignore b/.gitignore index 8c2b884..8bfc516 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,80 @@ # Built Visual Studio Code Extensions *.vsix +**/[b|B]in +**/[O|o]bj + +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates +*.env + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +[Aa][Rr][Mm]64[Ee][Cc]/ +bld/ +[Oo]bj/ +[Oo]ut/ +[Ll]og/ +[Ll]ogs/ + +# Build results on 'Bin' directories +**/[Bb]in/* +# Uncomment if you have tasks that rely on *.refresh files to move binaries +# (https://github.com/github/gitignore/pull/3736) +#!**/[Bb]in/*.refresh + +# Visual Studio 2015/2017 cache/options directory +.vs/ + +# Covers JetBrains IDEs: IntelliJ, GoLand, 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 + diff --git a/.idea/.idea.service/.idea/.gitignore b/.idea/.idea.service/.idea/.gitignore new file mode 100644 index 0000000..6a1f9e2 --- /dev/null +++ b/.idea/.idea.service/.idea/.gitignore @@ -0,0 +1,13 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Rider ignored files +/modules.xml +/contentModel.xml +/projectSettingsUpdater.xml +/.idea.service.iml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/.idea.service/.idea/.name b/.idea/.idea.service/.idea/.name new file mode 100644 index 0000000..ce93fac --- /dev/null +++ b/.idea/.idea.service/.idea/.name @@ -0,0 +1 @@ +service \ No newline at end of file diff --git a/.idea/.idea.service/.idea/indexLayout.xml b/.idea/.idea.service/.idea/indexLayout.xml new file mode 100644 index 0000000..7b08163 --- /dev/null +++ b/.idea/.idea.service/.idea/indexLayout.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/.idea.service/.idea/vcs.xml b/.idea/.idea.service/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/.idea.service/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ee7580d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,8 @@ +# Normally, we would use a multi-stage build, but for simplicity, we are using a single stage here. +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +WORKDIR /src +COPY . . +RUN dotnet restore \ + && dotnet publish -c release -o /app +WORKDIR /app +ENTRYPOINT ["dotnet", "service.dll"] diff --git a/README.md b/README.md index 2c04612..1b4e2e5 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,138 @@ # dotnet-interview-exercise +This exercise is designed to test your skills in C# and .NET. + +## Technologies used in this exercise + +* .NET 8.0 / C# +* Minimal ASP.NET Core Web API +* Entity Framework Core +* PostgreSQL Database (Docker) +* HttpClient for downstream REST API calls +* ??? + +## Prerequisites + +* Latest [.NET 8 SDK](https://dotnet.microsoft.com/en-us/download/dotnet/8.0) +* Visual Studio Code +* Docker Desktop and docker compose plugin +* **Disable Copilot completions** (that would make it too easy...) +* Be ready for sharing your screen in Zoom +* Launch settings have been configured to run and debug the web application + +## Exercise + +### Phase 1 + +Note: For all API and schemas, see . + +* Implement the missing code in JsonPlaceholderClient + * Setup of the client. + * Method for fetching one post from the external API at `https://jsonplaceholder.typicode.com/posts/{id}`. +* In our endpoint handler, fetch the post with the ID provided. +* Store the post received in the database using the `Post` entity, but leave out `UpdatedAt` for now. +* Use for testing. + +```mermaid +sequenceDiagram + title Overview + actor B as Swagger UI + participant S as Service + participant E as External Service + participant D as Database + B->>S: GetPostById + S<<->>E: GetPostById + S->>D: Store Post + S->>B: Return Post +``` + +**Success Criteria: Verify that the post has been saved with the database with:** + +```bash +$ docker compose exec --env PGPASSWORD=password db psql -h localhost -U user --db mydatabase -c "select * from posts;" + + id | user_id | title | body | updated_at +----+---------+----------------------------------------------------------------------------+-----------------------------------------------------+------------------------ + 1 | 1 | sunt aut facere repellat provident occaecati excepturi optio reprehenderit | quia et suscipit +| 1970-01-01 00:00:00+00 + | | | suscipit recusandae consequuntur expedita et cum +| + | | | reprehenderit molestiae ut ut quas totam +| + | | | nostrum rerum est autem sunt rem eveniet architecto | +(1 row) +``` + +### Phase 2 + +* Check whether we already have the post in the database. +* If we do, return the post from the database, but without the internal `updated_at` timestamp. +* If we don't, fetch the post from the external API and store it in the database, and return it. +* **Success Criteria: Check the service logs to see if the post was read from the database, or whether the client made a call to the downstream service.** + +### Phase 3 + +* Set the `UpdatedAt` property of the `Post` entity to the current date and time when storing it in the database. +* If the post in the database has been updated less than 1 minute ago, return the one from the database. +* If the post in the database is older than 1 minute (extra short value for testing), fetch the post from the external API again and update it in the database before returning. +* **Success Criteria: Check the service logs to see if the post was read from the database, or whether the client made a call to the downstream service.** + +### Phase 4 + +* Think about how we could improve resiliency in downstream API calls. +* For setup, pull in the changes from the `phase4` branch. + * It adds chaos-injecting code in `Program.cs` to simulate random failures in downstream API calls. + * Execute a couple of requests with increasing ids until an error happens. +* Implement resiliency measures to handle these kinds of failures. +* **Success Criteria: Check the service logs to see the resiliency measures in action.** + +### Phase 5 + +* Now increase the chaos outcomes rate of 503 to 95% to simulate a partial outage. What is the outcome on our endpoint? +* Improve the error response returned by handling the downstream error accordingly. +* **Success Criteria: Service should return the appropriate error response that warrants retrying the call from the client side.** + +### Bonus Phase 6 (discussion only) + +* The current DB schema solution has big drawbacks. What are they? +* How would you implement schema migrations that avoid these issues? +* What do you need to consider in the setup for proper schema migrations? + +### Bonus Phase 7 (discussion only) + +* Imagine the following deployment scenario: + * The service is deployed to and running in the Elastic Container Service (ECS) in Amazon AWS + * The downstream service requires authentication +* How would you store and retrieve the required credentials? +* What options do you see to also require authentication on our little service? + +## Troubleshooting + +### Start database manually + +Open a terminal at the repo root and execute + +```bash +$ docker compose up -d db + +[+] Running 3/3 + ✔ Network dotnet-interview-exercise_default Created 0.0s + ✔ Volume "dotnet-interview-exercise_db_data" Created 0.0s + ✔ Container dotnet-interview-exercise-db-1 Started 0.1s +``` + +### Cleanup database + +In case something went wrong, wipe and rebuild the database + +```bash +$ docker compose down -v + +[+] Running 3/3 + ✔ Container dotnet-interview-exercise-db-1 Removed. 0.2s + ✔ Network dotnet-interview-exercise_default Removed. 0.2s + ✔ Volume dotnet-interview-exercise_db_data Removed. 0.0s + +$ docker compose up -d db +[+] Running 3/3 + ✔ Network dotnet-interview-exercise_default Created 0.0s + ✔ Volume "dotnet-interview-exercise_db_data" Created 0.0s + ✔ Container dotnet-interview-exercise-db-1 Started 0.1s +``` diff --git a/dbschemas/01-create-posts-table.sql b/dbschemas/01-create-posts-table.sql new file mode 100644 index 0000000..2c603ca --- /dev/null +++ b/dbschemas/01-create-posts-table.sql @@ -0,0 +1,7 @@ +-- Schema generated from this sample: https://jsonplaceholder.typicode.com/posts/1 +CREATE TABLE posts ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL, + title TEXT NOT NULL, + body TEXT NOT NULL +); diff --git a/dbschemas/02-posts-add-updated.sql b/dbschemas/02-posts-add-updated.sql new file mode 100644 index 0000000..b44f7ec --- /dev/null +++ b/dbschemas/02-posts-add-updated.sql @@ -0,0 +1,3 @@ +-- This SQL script adds a new column 'updated_at' to the 'posts' table. +ALTER TABLE posts +ADD COLUMN updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP; diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..76e9eca --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,36 @@ +services: + service: + build: + context: . + dockerfile: Dockerfile + depends_on: + - db + networks: + - default + ports: + - "8080:8080" + environment: + ASPNETCORE_ENVIRONMENT: Docker + + db: + image: postgres:alpine + restart: unless-stopped + networks: + - default + ports: + - "5432:5432" # Also for local development + environment: + POSTGRES_USER: user + POSTGRES_PASSWORD: password + POSTGRES_DB: mydatabase + volumes: + - db_data:/var/lib/postgresql/data + - ./dbschemas:/docker-entrypoint-initdb.d + +networks: + default: + driver: bridge + +volumes: + db_data: + driver: local diff --git a/service.sln b/service.sln new file mode 100644 index 0000000..42b7688 --- /dev/null +++ b/service.sln @@ -0,0 +1,38 @@ + +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}") = "service", "service\service.csproj", "{8572A3DF-7A4C-4552-8B30-22D794E703A6}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{8EC462FD-D22E-90A8-E5CE-7E832BA40C5D}" + ProjectSection(SolutionItems) = preProject + docker-compose.yaml = docker-compose.yaml + Dockerfile = Dockerfile + README.md = README.md + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "dbschemas", "dbschemas", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}" + ProjectSection(SolutionItems) = preProject + dbschemas\01-create-posts-table.sql = dbschemas\01-create-posts-table.sql + dbschemas\02-posts-add-updated.sql = dbschemas\02-posts-add-updated.sql + EndProjectSection +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {8572A3DF-7A4C-4552-8B30-22D794E703A6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8572A3DF-7A4C-4552-8B30-22D794E703A6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8572A3DF-7A4C-4552-8B30-22D794E703A6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8572A3DF-7A4C-4552-8B30-22D794E703A6}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {DD652F00-A6DC-4579-AF4C-12EA8B5C7ECC} + EndGlobalSection +EndGlobal diff --git a/service/AppDbContext.cs b/service/AppDbContext.cs new file mode 100644 index 0000000..709d37b --- /dev/null +++ b/service/AppDbContext.cs @@ -0,0 +1,10 @@ +using Microsoft.EntityFrameworkCore; + +namespace service; + +public class AppDbContext : DbContext +{ + public AppDbContext(DbContextOptions options) : base(options) { } + + public DbSet Posts { get; set; } +} \ No newline at end of file diff --git a/service/JsonPlaceholderClient.cs b/service/JsonPlaceholderClient.cs new file mode 100644 index 0000000..5f6fda0 --- /dev/null +++ b/service/JsonPlaceholderClient.cs @@ -0,0 +1,24 @@ +using System; +using System.Net.Http; +using System.Net.Http.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace service; + +public class JsonPlaceholderClient +{ + private readonly HttpClient _client; + + public JsonPlaceholderClient(HttpClient client) + { + _client = client; + // TODO: Configure the client as needed. + } + + public async Task GetPostByIdAsync(int id, CancellationToken ct = default) + { + // TODO: Implement the logic to call the external service and retrieve the post by ID. + throw new NotImplementedException("This method will be implemented in Phase 1."); + } +} diff --git a/service/Post.cs b/service/Post.cs new file mode 100644 index 0000000..c078dda --- /dev/null +++ b/service/Post.cs @@ -0,0 +1,27 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace service; + +[Table("posts")] +public class Post +{ + [Key] + [Column("id")] + public int Id { get; set; } + + [Column("user_id")] + public int UserId { get; set; } + + [Column("title")] + [Required] + public string Title { get; set; } + + [Column("body")] + [Required] + public string Body { get; set; } + + [Column("updated_at")] + public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.Parse("1970-01-01T00:00:00Z"); // TODO: Remove the default value in Phase 3. +} \ No newline at end of file diff --git a/service/PostModel.cs b/service/PostModel.cs new file mode 100644 index 0000000..b90061f --- /dev/null +++ b/service/PostModel.cs @@ -0,0 +1,12 @@ +using System; + +namespace service; + +[Serializable] +public class PostModel +{ + public int Id { get; set; } + public int UserId { get; set; } + public string Title { get; set; } + public string Body { get; set; } +} diff --git a/service/Program.cs b/service/Program.cs new file mode 100644 index 0000000..afc49f9 --- /dev/null +++ b/service/Program.cs @@ -0,0 +1,46 @@ +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Polly.Simmy; + +using service; + +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. +var services = builder.Services; +services.AddRequestTimeouts(); +// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle +services.AddEndpointsApiExplorer(); +services.AddSwaggerGen(); +services.AddDbContext(optionsBuilder => +{ + // Configure the database connection string. + var connectionString = builder.Configuration.GetValue("PostgresConnection"); + Console.WriteLine($"Connecting to PostgreSQL database with connection string: {connectionString}"); + optionsBuilder.UseNpgsql(connectionString); +}); +var httpClientBuilder = services.AddHttpClient(); + +var app = builder.Build(); +app.UseSwagger(); +app.UseSwaggerUI(); // Swagger UI is available at http://localhost:8080/swagger/index.html +app.UseRequestTimeouts(); + +app.MapGet("/posts/{id}", async (AppDbContext dbContext, JsonPlaceholderClient client, int id) => +{ + // TODO: (Phase 1) Implement the logic to retrieve a post by ID and store it in the database. + // Consider some minimal error handling in case the post is not found (e.g. Id > 100). + return Results.Ok(); +}) +.WithRequestTimeout(TimeSpan.FromSeconds(29)) +.WithName("GetPostById") +.WithOpenApi(); + +app.Run(); diff --git a/service/Properties/launchSettings.json b/service/Properties/launchSettings.json new file mode 100644 index 0000000..1eeeb7c --- /dev/null +++ b/service/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "profiles": { + "service": { + "commandName": "Project", + "launchBrowser": true, + "useSSL": false, + "launchUrl": "http://localhost:8080/swagger/index.html", + "applicationUrl": "http://localhost:8080/swagger/index.html", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + } + } +} \ No newline at end of file diff --git a/service/appsettings.Docker.json b/service/appsettings.Docker.json new file mode 100644 index 0000000..62ea203 --- /dev/null +++ b/service/appsettings.Docker.json @@ -0,0 +1,3 @@ +{ + "PostgresConnection": "Server=db;Database=mydatabase;User Id=user;Password=password;" +} diff --git a/service/appsettings.json b/service/appsettings.json new file mode 100644 index 0000000..5b9f5e1 --- /dev/null +++ b/service/appsettings.json @@ -0,0 +1,11 @@ +{ + "Urls": "http://0.0.0.0:8080", + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "PostgresConnection": "Server=localhost;Database=mydatabase;User Id=user;Password=password;" +} diff --git a/service/service.csproj b/service/service.csproj new file mode 100644 index 0000000..40a28cc --- /dev/null +++ b/service/service.csproj @@ -0,0 +1,20 @@ + + + + net8.0 + enable + + + + + + + + + + + + + + +