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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+