A proof of concept API to explore best-practices and new ideas
Explore the docs »
View Demo
·
Report Bug
·
Request Feature
Table of Contents
A proof of concept API to explore best-practices and new ideas
This application depends on Docker for databases and an API deployment location.
Docker install and setup details can be found here: Local Docker
The design of this application was based upon the OpenAPI specification, found in the shared\PilotSharedSource directory (which is a submodule of PilotSharedSource).
-
Clone the repo (including submodules)
git clone --recurse-submodules https://github.com/MikeLooper/PilotApiDotNet.git -
If the repository was cloned without submodules, initialize them:
git submodule update --init --recursive -
Open the .sln file in Visual Studio.
-
Press F5 to build and run the application.
This repository includes PilotSharedSource as a Git submodule at shared/PilotSharedSource/.
To pull the latest submodule changes from its tracked branch:
git submodule update --remote --recursive shared/PilotSharedSource
After updating, commit the changed submodule pointer in this repository:
git add shared/PilotSharedSource .gitmodules
git commit -m "Update PilotSharedSource submodule"
When Executing locally, a User Secrets file will be needed to provide local connection details.
This User Secrets file will look like the following:
{
"Application": {
"DataConnections": [
{
"Active": true,
"ConnectTimeout": 30,
"DataSourceName": "NorthWind_SQL",
"Host": "localhost",
"Password": "<DevUser password for SQL Server>",
"Port": 1433,
"UserName": "DevUser"
},
{
"Active": false,
"ConnectTimeout": 30,
"DataSourceName": "NorthWind_Pgs",
"Host": "localhost",
"Password": "<DevUser password for PostgreSQL>",
"Port": 5432,
"UserName": "DevUser"
}
],
"OpenTelemetry": {
"Server": "localhost",
"Port": 4318
}
}
}
The User Secrets file is located at %APPDATA%\Microsoft\UserSecrets\<user_secrets_id>\secrets.json.
The application configuration is broken into three files:
- appsettings.json
- appsettings.Development.json
- appsettings.Production.json
This separation simplifies changing which data connection is active for a specific deployment and provides separated locations for sensitive values.
The data source values for Development are included in the local development config file, which can be found in the Web project:
- appsettings.Development.json
The data source values for Production are included in the docker-deploy config files, as noted here:
- SQL Server:
- ..\docker\SqlServer\appsettings.Production.json
- PostgreSQL:
- ..\docker\PostgreSQL\appsettings.Production.json
{
"Application": {
"DataSources": [
{
"Active": true,
"DataSourceName": "NorthWind_SQL",
"DataSource": "NorthWind",
"DataSourceType": "SqlServer",
"Schema": "dbo"
},
{
"Active": true,
"DataSourceName": "NorthWind_Pgs",
"DataSource": "northwind",
"DataSourceType": "PostgreSQL",
"Schema": "pilot"
}
],
"Security": {
"Active": true,
"BaseUrl": "http://local-keycloak:8080",
"Realm": "local-realm",
"ClientId": "local-client",
"RequireHttpsMetadata": false,
"ClockSkewSeconds": 60
},
"OpenApi": {
"Title": "PilotApiDotNet",
"Contact": {
"Email": "MikelLooper@gmail.com",
"Name": "Michael Looper",
"URL": "https://github.com/MikeLooper/PilotApiDotNet"
},
"Description": "A proof of concept API to explore best-practices and new ideas (.NET/C#)",
"License": "MIT",
"Summary": "Proof of concept API",
"Version": "0.1.1"
},
"OpenTelemetry": {
"Server": "otel-collector",
"Port": 4318
}
},
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"Microsoft.AspNetCore.Hosting.Diagnostics": "Error",
"Microsoft.Hosting.Lifetime": "Information",
"System": "Warning"
}
},
"WriteTo": [
{ "Name": "Console" },
{
"Name": "File",
"Args": {
"path": "logs/log-.json",
"rollingInterval": "Day",
"rollOnFileSizeLimit": true,
"fileSizeLimitBytes": 104857600,
"retainedFileCountLimit": 14,
"formatter": "Serilog.Formatting.Compact.CompactJsonFormatter, Serilog.Formatting.Compact"
}
}
],
"Enrich": [
"FromLogContext",
"WithMachineName",
"WithProcessId",
"WithThreadId",
"WithExceptionDetails"
]
},
"AllowedHosts": "*"
}{
"Application": {
"DataConnections": [
{
"Active": true,
"ConnectTimeout": 30,
"DataSourceName": "NorthWind_SQL",
"Host": "local_mssql",
"Password": "<DevUser password>",
"Port": 1433,
"UserName": "DevUser"
},
{
"Active": false,
"ConnectTimeout": 30,
"DataSourceName": "NorthWind_Pgs",
"Host": "local_postgres",
"Password": "<DevUser password>",
"Port": 5432,
"UserName": "DevUser"
}
]
}
}The configuration that controls the application logic is in the "Application" section.
An array of data connections settings.
Is the current section of settings active? Available options: true, false.
Within the DataConnections section, only one setting group can be active at one time.
The number of seconds for the data source timeout.
The name of the current data source section.
This name will match A DataSources.DataSourceName setting.
The name of the host for the data source.
When running locally for development, this value would typically be "localhost".
When deployed to a Docker container, this value would typically be the name of the data source container.
Examples: "local_mssql", "local_postgres"
The password for the user for the data source.
The active port for the target data source.
The name of the user for the data source.
The available data sources (such as a database).
Is the current section of settings active? Available options: true, false.
The name of the current data source section.
This name will match A DataConnections.DataSourceName setting.
The name of the data source, such as a database name.
The type of data source. Available values: "SqlServer", "PostgreSQL"
The schema where the target tables would be found.
The settings that control authentication (JWT/OAuth2) and role-based authorization against Security. See Calling a Secured Endpoint for usage details.
Is the current section of settings active? Available options: true, false.
When true, a missing/invalid token or an insufficient role for the requested HTTP verb returns a 401/403 response.
When false, the same failures do not block the request — it proceeds to the controller action, but the response includes a Warning header describing what failed. This is intended for staged rollout or incident response, not for production use.
The base URL of the Security server. Example (local development): "http://localhost:55001". Example (Docker deployment): "http://local-keycloak:8080".
The Security realm. Example: "local-realm".
The Security client Id. Example: "local-client".
Whether HTTPS is required when retrieving Security's metadata/signing keys. Should be true in production; false is typical for local development against an HTTP Security instance.
The clock skew tolerance, in seconds, applied when validating token expiry. Defaults to 60 (tighter than the framework default of 300), so a token isn't rejected purely due to minor clock drift between the API host and Security.
The settings that control how the OpenAPI specification is define within the application.
These values will typically appear in an API UI or in an extracted OpenAPI specification.
The API title.
A section of settings regarding who the point of contact is for this application.
The email address of the contact person.
The name of the contact person.
A web address relating to the contact person.
A description of this API.
The license relating to the source code for this application.
A summary of this API.
The application version.
The settings that control how OpenTelemetry (OTEL) is configured within the application.
The OpenTelemetry server address.
The OpenTelemetry server port.
When troubleshooting OTEL, you can check for port usages, at the command line:
netstat -ano | findstr 4318
When working correctly, this will result in something similar to the following:
Proto Local Address Foreign Address State PID
...
TCP 0.0.0.0:4318 0.0.0.0:0 LISTENING 25384
TCP [::]:4318 [::]:0 LISTENING 25384
TCP [::1]:4318 [::]:0 LISTENING 46888
TCP [::1]:4318 [::1]:59028 TIME_WAIT 0
...
The, start the command (via Win+R) the resmon.exe application. Locate the PID, from the port listing, on the PID column.
Examples fro the above:
| PID | Application |
|---|---|
| 25384 | Docker Desktop Backend |
| 46888 | Windows Subsystem for Linux |
The application exports traces, metrics, and logs via OpenTelemetry (OTEL) auto-instrumentation.
Telemetry is sent over OTLP/gRPC to a local OpenTelemetry Collector, which routes it to a Grafana LGTM stack (Tempo for traces, Mimir for metrics, Loki for logs):
PilotApiDotNet --OTLP/gRPC--> otel-collector --> Tempo / Mimir / Loki --> Grafana
View traces, metrics, and logs in Grafana at http://localhost:3000.
Application logs land in Loki. In Grafana, open Explore, select the Loki datasource, and query using LogQL, filtering on these labels:
| Label | Example value | Purpose |
|---|---|---|
service_name |
PilotApiDotNet |
Scope to this API |
deployment_environment |
development / production |
Filter by environment (from OTEL_DEPLOYMENT_ENVIRONMENT) |
detected_level / severity_text |
info, warn, error |
Filter by log level |
code_namespace |
PilotApi.Shared.Helpers.SecurityHelper |
Filter by originating Java class |
host_name |
(machine name) | Filter by the host the API ran on |
Example queries:
# All PilotApiDotNet logs
{service_name="PilotApiDotNet"}
# Errors and warnings only
{service_name="PilotApiDotNet", detected_level=~"error|warn"}
# Logs from a specific class
{service_name="PilotApiDotNet", code_namespace="PilotApi.Shared.Helpers.SecurityHelper"}
# Production logs mentioning a specific route
{service_name="PilotApiDotNet", deployment_environment="production"} |= "/v1/categories"
Each log entry also carries trace_id/span_id (when logged within a traced request), so you can jump from a log line directly to its trace in Tempo.
This application will be deployed to a Docker container. Deploy instructions can be found in the Docker README.
Once deployed to Docker, the application will be accessible at https://localhost:55551/....
For Exmaple, the Categories endpoint for GetAll will be accessible at http://localhost:55551/categories/get-all.
The API contract for this project is defined in the OpenAPI specification file located at docs/openapi.yaml.
You can use this file to generate client code or to explore the API using tools like Swagger UI or Bruno.
To get a visual representation of the API, you can use the Swagger editor by navigating to https://editor.swagger.io/.
You can also interact with the API using the Swagger UI by navigating to https://localhost:5001/swagger after running the application locally with Visual Studio.
All endpoints are secured with a Security-issued JWT (OAuth2), except /healthcheck and /about, which always remain open.
For local development, obtain an access token directly from Security using the Resource Owner Password grant:
curl -X POST "http://localhost:55001/realms/local-realm/protocol/openid-connect/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=password" \
-d "client_id=local-client" \
-d "username=working_admin_user" \
-d "password=<the user's password>"
Note: the Resource Owner Password grant sends a user's password directly from the calling client, which is convenient for local development and scripting, but should not be used by real/production clients — those should use the Authorization Code flow with PKCE instead.
Prerequisite: the
local-clientclient in Keycloak must have Direct access grants enabled (Keycloak Admin Console → realmlocal-realm→ Clients →local-client→ Settings/Capability config). Without it, Keycloak rejects every request from this grant type with400 {"error":"unauthorized_client","error_description":"Client not allowed for direct access grants"}— before it even checks the username/password — and neither thiscurlcommand nor the Bruno collection below can obtain a token.
Include the returned access_token as a bearer token on the request:
curl -H "Authorization: Bearer <access_token>" "http://localhost:55551/v1/categories/get-all"
Roles are not read from Security token role claims. They are looked up (via a UserRoles repository) using the token's preferred_username claim:
| UserId | Role | Allowed HTTP Methods |
|---|---|---|
| reader_user | ReadOnly | GET |
| working_user | ReadWrite | GET, POST, PUT |
| working_admin_user | Admin | GET, POST, PUT, DELETE |
A request using a verb outside of the assigned role's allowed methods is treated as an authorization failure.
- 401 Unauthorized — the request had no token, or the token was missing/invalid/expired, and
Security.Activeistrue. - 403 Forbidden — the request was authenticated, but the resolved role does not permit the requested HTTP verb, and
Security.Activeistrue. Security.Active=false— either of the failures above no longer blocks the request; it proceeds to the controller action, but the response includes aWarningheader describing what failed (e.g. "Token expired.", "Missing or invalid bearer token.", or the specific role/verb mismatch). This is intended for staged rollout or incident response, not for production use.
This API only validates bearer tokens; it does not issue or refresh them (it is a Security resource server, not a token issuer). Token expiry is enforced on every request, with a small clock-skew tolerance (ClockSkewSeconds, default 60 seconds) so a token isn't rejected purely due to minor clock drift between the API host and Security.
When a request fails with 401 because the access token expired, the client must call Security's token endpoint again with grant_type=refresh_token (using the refresh_token value returned alongside the original access_token) to obtain a new access_token, then retry the original request:
curl -X POST "http://localhost:55001/realms/local-realm/protocol/openid-connect/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=refresh_token" \
-d "client_id=local-client" \
-d "refresh_token=<refresh_token from the original token response>"
The Bruno collection (test/Bruno/PilotApiDotNet) automates the token flow described above instead of requiring the manual curl steps. It has the same prerequisite as the curl flow above — the local-client Keycloak client must have Direct access grants enabled, or every "Get Access Token" attempt fails with a generic "No access token received" error in the Bruno UI (the underlying 400 unauthorized_client from Keycloak isn't surfaced).
Authentication is configured once, as an OAuth2 "Password Credentials" auth block on the collection root (test/Bruno/PilotApiDotNet/opencollection.yml). Every folder and request in the collection defaults to auth: inherit, and Bruno resolves that by walking up the tree to the nearest folder with a concrete (non-inherit, non-none) auth override, falling all the way back to the collection root if none is found — so this single config is automatically picked up by v1 today, and by any future v2 folder added as a sibling, with nothing further to configure.
Bruno's inheritance only stops at a folder when that folder or one of its own requests carries an explicit, concrete auth override — a folder set to none is itself skipped when resolving its descendants, exactly like inherit is. Because of that, the three requests under System (About_NoDetails, About_WithDetails, HealthCheck) each carry no auth entry of their own (equivalent to none) directly on the request, not on the System folder — that is what actually stops them from picking up the collection-level token. Any new request added under System must do the same (omit auth, or explicitly set it, rather than leaving the inherited default) to stay token-free.
Bruno fetches an access token from Security's token endpoint ({{IdpHostBaseUrl}}/realms/{{IdpHostRealm}}/protocol/openid-connect/token) the first time a request under v1 is sent, caches it, adds it to the request as Authorization: Bearer <access_token>, and automatically refreshes it (using the refresh_token grant) once it expires. No manual token copy/paste is required.
The collection root also sets two headers (Accept: application/json, ApiVersion: 1) that apply to every request, System included — headers merge cumulatively from the collection root down through each folder to the request, so there is no need to repeat them anywhere else in the tree.
Note:
test/Bruno/opencollection.yml(one directory above the collection) is not part of this collection and has no effect on it. Bruno collections are self-contained — everything (auth, headers, variables) is scoped to the single directory containing the collection's ownopencollection.yml(here,test/Bruno/PilotApiDotNet); nothing cascades in from a parent directory. The only cross-collection concept Bruno has is a workspace, which requires a file literally namedworkspace.ymland, even then, only shares named global environments — never headers or auth. Define any collection-wide request headers/auth directly intest/Bruno/PilotApiDotNet/opencollection.yml, not in the outer file.Also: if this collection is open in the Bruno app while these files are edited by hand, saving anything from Bruno's UI rewrites the whole file from Bruno's in-memory copy, silently reverting the on-disk changes. Close the collection in Bruno (or reload it) after editing these files externally, and re-open/reload it before making further changes in the app.
The token request is built from variables defined for the collection:
| Variable | Purpose |
|---|---|
IdpHostBaseUrl |
Base URL of the Security server, matching Application.Security.BaseUrl. |
IdpHostRealm |
The Security realm, matching Application.Security.Realm. |
IdpHostClientId |
The Security client Id, matching Application.Security.ClientId. |
IdpHostUsername |
The user to authenticate as. Defaults to working_admin_user (Admin role), so every request in the collection — including Add, Update, and Delete — works without editing anything. |
The token request also includes a variable defined for the general environment:
| Variable | Purpose |
|---|---|
IDP_HOST_PASSWORD |
The password for IdpHostUsername. |
The above value is stored in a .env file inside the collection root, test/Bruno/PilotApiDotNet/.env (Bruno only looks for .env directly alongside the collection's opencollection.yml, not in a parent folder). This file must be created manually before opening Bruno:
- Copy
test/Bruno/PilotApiDotNet/.env.exampletotest/Bruno/PilotApiDotNet/.env. - Change the
<host-password>value to the current password for theIdpHostUsernameuser.
.env is git-ignored; .env.example is intentionally excluded from that ignore rule so it stays committed as the template for other developers.
- API (.NET) version of the API (this code)
- Java version of the API
- Deploy API to Docker
- Angular Frontend User Interface (UI) to consume APIs in Docker
See the open issues for a full list of proposed features (and known issues).
Contributions are what make the open source community such an amazing place to learn, inspire, and create. Any contributions you make are greatly appreciated.
If you have a suggestion that would make this better, please fork the repo and create a pull request. You can also simply open an issue with the tag "enhancement". Don't forget to give the project a star! Thanks again!
- Fork the Project
- Create your Feature Branch (
git checkout -b feature/AmazingFeature) - Commit your Changes (
git commit -m 'Add some AmazingFeature') - Push to the Branch (
git push origin feature/AmazingFeature) - Open a Pull Request
This repository uses two Copilot instruction layers:
- Repository-wide guidance:
.github/copilot-instructions.md - Unit-test-specific guidance:
.github/instructions/unit-tests.instructions.md
How to use them:
- The repository-wide file is intentionally minimal and applies across all work.
- The unit-test file is scoped to
test/**/*.csand applies to unit test creation and maintenance tasks. - For unit test work, follow the NUnit and test-structure rules in the scoped instruction file.
Distributed under the MIT License. See LICENSE.txt for more information.
Michael Looper - MikelLooper@gmail.com
Project Link: https://github.com/MikeLooper/PilotApi