Compare commits

3 Commits

Author SHA1 Message Date
424bb5c178 Upgrade to .NET 9 and update nuget packages 2024-12-01 19:28:46 +01:00
79d950d8a2 Create web api project 2024-09-06 18:28:35 +02:00
50fc323b9e Add gitignore 2024-09-06 18:13:43 +02:00
123 changed files with 159 additions and 9858 deletions

View File

@@ -1,7 +1,7 @@
**/.classpath
**/.dockerignore
**/.env
#**/.git
**/.git
**/.gitignore
**/.project
**/.settings

View File

@@ -1,87 +0,0 @@
kind: pipeline
type: docker
name: Build and test
trigger:
event:
include:
- push
- pull_request
- custom
steps:
- name: compile
image: mcr.microsoft.com/dotnet/sdk:9.0-alpine
environment:
CI_WORKSPACE: "/drone/src"
commands:
- dotnet build
volumes:
- name: dockersock
path: /var/run
- name: test
image: quay.io/testcontainers/dind-drone-plugin
environment:
CI_WORKSPACE: "/drone/src"
settings:
build_image: mcr.microsoft.com/dotnet/sdk:9.0-alpine
cmd:
- dotnet test --no-build
volumes:
- name: dockersock
path: /var/run
depends_on:
- compile
- name: docker build and push
image: docker:24.0.7
commands:
- docker build . -t $docker_registry$docker_repo:$DRONE_BRANCH
- echo $docker_password | docker login --username $docker_username --password-stdin $docker_registry
- docker push $docker_registry$docker_repo:$DRONE_BRANCH
environment:
docker_username:
from_secret: docker_username
docker_password:
from_secret: docker_password
docker_repo:
from_secret: docker_repo
docker_registry:
from_secret: docker_registry
volumes:
- name: dockersock
path: /var/run
when:
branch:
- main
depends_on:
- compile
- test
- name: Telegram notification
image: appleboy/drone-telegram
settings:
token:
from_secret: telegram_token
to:
from_secret: telegram_user_id
when:
status:
- failure
depends_on:
- compile
- test
- docker build and push
services:
- name: docker
image: docker:dind
privileged: true
volumes:
- name: dockersock
path: /var/run
volumes:
- name: dockersock
temp: { }

View File

@@ -1,271 +0,0 @@
# Remove the line below if you want to inherit .editorconfig settings from higher directories
root = true
# C# files
[*.cs]
#### Core EditorConfig Options ####
# Indentation and spacing
indent_size = 4
indent_style = tab
tab_width = 4
# New line preferences
end_of_line = crlf
insert_final_newline = false
#### .NET Coding Conventions ####
# Organize usings
dotnet_separate_import_directive_groups = false
dotnet_sort_system_directives_first = false
file_header_template = unset
# this. and Me. preferences
dotnet_style_qualification_for_event = false
dotnet_style_qualification_for_field = false
dotnet_style_qualification_for_method = false
dotnet_style_qualification_for_property = false
# Language keywords vs BCL types preferences
dotnet_style_predefined_type_for_locals_parameters_members = true
dotnet_style_predefined_type_for_member_access = true
# Parentheses preferences
dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity
dotnet_style_parentheses_in_other_binary_operators = always_for_clarity
dotnet_style_parentheses_in_other_operators = never_if_unnecessary
dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity
# Modifier preferences
dotnet_style_require_accessibility_modifiers = for_non_interface_members
# Expression-level preferences
dotnet_style_coalesce_expression = true
dotnet_style_collection_initializer = true
dotnet_style_explicit_tuple_names = true
dotnet_style_namespace_match_folder = true
dotnet_style_null_propagation = true
dotnet_style_object_initializer = true
dotnet_style_operator_placement_when_wrapping = beginning_of_line
dotnet_style_prefer_auto_properties = true:suggestion
dotnet_style_prefer_collection_expression = when_types_loosely_match
dotnet_style_prefer_compound_assignment = true
dotnet_style_prefer_conditional_expression_over_assignment = true
dotnet_style_prefer_conditional_expression_over_return = true
dotnet_style_prefer_foreach_explicit_cast_in_source = when_strongly_typed
dotnet_style_prefer_inferred_anonymous_type_member_names = true
dotnet_style_prefer_inferred_tuple_names = true
dotnet_style_prefer_is_null_check_over_reference_equality_method = true
dotnet_style_prefer_simplified_boolean_expressions = true
dotnet_style_prefer_simplified_interpolation = true
# Field preferences
dotnet_style_readonly_field = true
# Parameter preferences
dotnet_code_quality_unused_parameters = all
# Suppression preferences
dotnet_remove_unnecessary_suppression_exclusions = none
# New line preferences
dotnet_style_allow_multiple_blank_lines_experimental = false:suggestion
dotnet_style_allow_statement_immediately_after_block_experimental = false:suggestion
#### C# Coding Conventions ####
# var preferences
csharp_style_var_elsewhere = false
csharp_style_var_for_built_in_types = false
csharp_style_var_when_type_is_apparent = false
# Expression-bodied members
csharp_style_expression_bodied_accessors = true:silent
csharp_style_expression_bodied_constructors = false:silent
csharp_style_expression_bodied_indexers = true:silent
csharp_style_expression_bodied_lambdas = true:silent
csharp_style_expression_bodied_local_functions = true:silent
csharp_style_expression_bodied_methods = false:silent
csharp_style_expression_bodied_operators = false:silent
csharp_style_expression_bodied_properties = true:silent
# Pattern matching preferences
csharp_style_pattern_matching_over_as_with_null_check = true
csharp_style_pattern_matching_over_is_with_cast_check = true
csharp_style_prefer_extended_property_pattern = true
csharp_style_prefer_not_pattern = true
csharp_style_prefer_pattern_matching = true
csharp_style_prefer_switch_expression = true
# Null-checking preferences
csharp_style_conditional_delegate_call = true
# Modifier preferences
csharp_prefer_static_local_function = true
csharp_preferred_modifier_order = public,private,protected,internal,file,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,required,volatile,async
csharp_style_prefer_readonly_struct = true
csharp_style_prefer_readonly_struct_member = true
# Code-block preferences
csharp_prefer_braces = true:silent
csharp_prefer_simple_using_statement = true:suggestion
csharp_style_namespace_declarations = file_scoped:silent
csharp_style_prefer_method_group_conversion = true:silent
csharp_style_prefer_primary_constructors = true:suggestion
csharp_style_prefer_top_level_statements = true:silent
# Expression-level preferences
csharp_prefer_simple_default_expression = true
csharp_style_deconstructed_variable_declaration = true
csharp_style_implicit_object_creation_when_type_is_apparent = true
csharp_style_inlined_variable_declaration = true
csharp_style_prefer_index_operator = true
csharp_style_prefer_local_over_anonymous_function = true
csharp_style_prefer_null_check_over_type_check = true
csharp_style_prefer_range_operator = true
csharp_style_prefer_tuple_swap = true
csharp_style_prefer_utf8_string_literals = true
csharp_style_throw_expression = true
csharp_style_unused_value_assignment_preference = discard_variable
csharp_style_unused_value_expression_statement_preference = discard_variable
# 'using' directive preferences
csharp_using_directive_placement = outside_namespace:silent
# New line preferences
csharp_style_allow_blank_line_after_colon_in_constructor_initializer_experimental = true
csharp_style_allow_blank_line_after_token_in_arrow_expression_clause_experimental = true
csharp_style_allow_blank_line_after_token_in_conditional_expression_experimental = true
csharp_style_allow_blank_lines_between_consecutive_braces_experimental = false:suggestion
csharp_style_allow_embedded_statements_on_same_line_experimental = true
#### C# Formatting Rules ####
# New line preferences
csharp_new_line_before_catch = true
csharp_new_line_before_else = true
csharp_new_line_before_finally = true
csharp_new_line_before_members_in_anonymous_types = true
csharp_new_line_before_members_in_object_initializers = true
csharp_new_line_before_open_brace = all
csharp_new_line_between_query_expression_clauses = true
# Indentation preferences
csharp_indent_block_contents = true
csharp_indent_braces = false
csharp_indent_case_contents = true
csharp_indent_case_contents_when_block = true
csharp_indent_labels = one_less_than_current
csharp_indent_switch_labels = true
# Space preferences
csharp_space_after_cast = false
csharp_space_after_colon_in_inheritance_clause = true
csharp_space_after_comma = true
csharp_space_after_dot = false
csharp_space_after_keywords_in_control_flow_statements = true
csharp_space_after_semicolon_in_for_statement = true
csharp_space_around_binary_operators = before_and_after
csharp_space_around_declaration_statements = false
csharp_space_before_colon_in_inheritance_clause = true
csharp_space_before_comma = false
csharp_space_before_dot = false
csharp_space_before_open_square_brackets = false
csharp_space_before_semicolon_in_for_statement = false
csharp_space_between_empty_square_brackets = false
csharp_space_between_method_call_empty_parameter_list_parentheses = false
csharp_space_between_method_call_name_and_opening_parenthesis = false
csharp_space_between_method_call_parameter_list_parentheses = false
csharp_space_between_method_declaration_empty_parameter_list_parentheses = false
csharp_space_between_method_declaration_name_and_open_parenthesis = false
csharp_space_between_method_declaration_parameter_list_parentheses = false
csharp_space_between_parentheses = false
csharp_space_between_square_brackets = false
# Wrapping preferences
csharp_preserve_single_line_blocks = true
csharp_preserve_single_line_statements = true
#### Naming styles ####
# Naming rules
dotnet_naming_rule.interface_should_be_begins_with_i.severity = warning
dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface
dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i
dotnet_naming_rule.types_should_be_pascal_case.severity = warning
dotnet_naming_rule.types_should_be_pascal_case.symbols = types
dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case
dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = warning
dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members
dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case
dotnet_naming_rule.private_or_internal_field_should_be_underscore.severity = warning
dotnet_naming_rule.private_or_internal_field_should_be_underscore.symbols = private_or_internal_field
dotnet_naming_rule.private_or_internal_field_should_be_underscore.style = underscore
dotnet_naming_rule.private_or_internal_static_field_should_be_sunderscore.severity = warning
dotnet_naming_rule.private_or_internal_static_field_should_be_sunderscore.symbols = private_or_internal_static_field
dotnet_naming_rule.private_or_internal_static_field_should_be_sunderscore.style = sunderscore
# Symbol specifications
dotnet_naming_symbols.interface.applicable_kinds = interface
dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.interface.required_modifiers =
dotnet_naming_symbols.private_or_internal_field.applicable_kinds = field
dotnet_naming_symbols.private_or_internal_field.applicable_accessibilities = internal, private, private_protected
dotnet_naming_symbols.private_or_internal_field.required_modifiers =
dotnet_naming_symbols.private_or_internal_static_field.applicable_kinds = field
dotnet_naming_symbols.private_or_internal_static_field.applicable_accessibilities = internal, private, private_protected
dotnet_naming_symbols.private_or_internal_static_field.required_modifiers = static
dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum
dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.types.required_modifiers =
dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method
dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.non_field_members.required_modifiers =
# Naming styles
dotnet_naming_style.pascal_case.required_prefix =
dotnet_naming_style.pascal_case.required_suffix =
dotnet_naming_style.pascal_case.word_separator =
dotnet_naming_style.pascal_case.capitalization = pascal_case
dotnet_naming_style.begins_with_i.required_prefix = I
dotnet_naming_style.begins_with_i.required_suffix =
dotnet_naming_style.begins_with_i.word_separator =
dotnet_naming_style.begins_with_i.capitalization = pascal_case
dotnet_naming_style.underscore.required_prefix = _
dotnet_naming_style.underscore.required_suffix =
dotnet_naming_style.underscore.word_separator =
dotnet_naming_style.underscore.capitalization = camel_case
dotnet_naming_style.sunderscore.required_prefix = s_
dotnet_naming_style.sunderscore.required_suffix =
dotnet_naming_style.sunderscore.word_separator =
dotnet_naming_style.sunderscore.capitalization = camel_case
[*.{cs,vb}]
dotnet_style_coalesce_expression = true:suggestion
dotnet_style_null_propagation = true:suggestion
dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion
dotnet_style_prefer_auto_properties = true:suggestion
dotnet_style_object_initializer = true:suggestion
dotnet_style_collection_initializer = true:suggestion
dotnet_style_prefer_simplified_boolean_expressions = true:suggestion
dotnet_style_prefer_conditional_expression_over_assignment = true:silent
dotnet_style_operator_placement_when_wrapping = beginning_of_line
tab_width = 4
indent_size = 4
end_of_line = crlf

View File

@@ -1,2 +0,0 @@
dotnet ef migrations add $args[0] --project .\src\WebApi\WebApi.csproj --output-dir Persistence/Migrations
dotnet ef migrations script --idempotent --project .\src\WebApi\WebApi.csproj --output migrations/migration.sql

View File

@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="Current" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<ItemGroup>
<PackageReference Include="Nerdbank.GitVersioning" Condition="!Exists('packages.config')">
<PrivateAssets>all</PrivateAssets>
<Version>3.6.141</Version>
</PackageReference>
</ItemGroup>
</Project>

View File

@@ -1,27 +0,0 @@
#See https://aka.ms/customizecontainer to learn how to customize your debug container and how Visual Studio uses this Dockerfile to build your images for faster debugging.
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
WORKDIR /app
EXPOSE 8080
EXPOSE 8081
RUN apt-get update && apt-get install -y curl
USER app
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["src/Vegasco.Server.Api/Vegasco.Server.Api.csproj", "src/Vegasco.Server.Api/"]
RUN dotnet restore "./src/Vegasco.Server.Api/Vegasco.Server.Api.csproj"
COPY . .
WORKDIR "/src/src/Vegasco.Server.Api"
RUN dotnet build "./Vegasco.Server.Api.csproj" -c $BUILD_CONFIGURATION -o /app/build
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "./Vegasco.Server.Api.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
HEALTHCHECK --interval=20s --timeout=1s --start-period=10s --retries=3 CMD curl --fail http://localhost:8080/health || exit 1
ENTRYPOINT ["dotnet", "Vegasco.Server.Api.dll"]

View File

@@ -1,67 +1 @@
# Vegasco Server
Backend for the vegasco (**VE**hicle **GAS** **CO**nsumption) application.
## Getting Started
### Configuration
| Configuration | Description | Default | Required |
|--------------------------|---------------------------------------------------------------------------------------------------------------|------------------------------------------------------------|----------|
| JWT:MetadataUrl | The oidc meta data url | - | true |
| JWT:ValidAudience | The valid audience of the JWT token. | - | true |
| JWT:NameClaimType | The claim type of the user's name claim. For keycloak, using `preferred_username` is often the better choice. | http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name | false |
| JWT:AllowHttpMetadataUrl | Whether to allow the meta data url to have http as protocol. Always true when `ASPNETCORE_ENVIRONMENT=true` | false | false |
The application uses the prefix `Vegasco_` for environment variable names. The prefix is removed when the application reads the environment variables and duplicate entries are overwritten by the environment variables.
Example:
- `foo=bar1`
- `Vegasco_foo=bar2`
Results in:
- `foo=bar2`
- `Vegasco_foo=bar2`
Configuration hierarchy in environment variables is usually denoted using a colon (`:`). But because on some systems the colon character is a reserved character, you can use a double underscore (`__`) as an alternative. The application will replace the double underscore with a colon when reading the environment variables.
Example:
The environment variable `foo__bar=value` (as well as `Vegasco_foo__bar=value`) will be converted to `foo:bar=value` in the application.
### Configuration examples
As environment variables:
```env
Vegasco_JWT__Authority=https://example.authority.com
Vegasco_JWT__Audience=example-audience
Vegasco_JWT__Issuer=https://example.authority.com/realms/example-realm/
Vegasco_JWT__NameClaimType=preferred_username
```
As appsettings.json (or a environment specific appsettings.*.json):
**Note: the `Vegasco_` prefix is only for environment variables**
```json
{
"JWT": {
"Authority": "https://example.authority.com/realms/example-realm",
"Audience": "example-audience",
"Issuer": "https://example.authority.com/realms/example-realm/",
"NameClaimType": "preferred_username"
}
}
```
### Running the application
The solution uses Aspire to orchestrate the application. Specifically, it introduces sensible service defaults, including but not limited to OpenTelemetry,
creates a Postgres database as a docker container, and starts the Api with the correct configuration to communicate with the database.
Ensure you have an identity provider set up, for example Keycloak, and configured the relevant options described above.
Then, to run the application, ensure you have Docker running, then run the `Vegasco.Server.AppHost` launch profile.
# vegasco-server

View File

@@ -1 +0,0 @@
docker run -d -p 5432:5432 --restart always --name vegasco-test-db -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres postgres:16.3-alpine

6
Vegasco.slnx Normal file
View File

@@ -0,0 +1,6 @@
<Solution>
<Project Path="src\Vegasco.WebApi\Vegasco.WebApi.csproj" Type="C#" />
<Properties Name="Visual Studio">
<Property Name="OpenWith" Value="Visual Studio Version 17" />
</Properties>
</Solution>

View File

@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
</packageSources>
</configuration>

View File

@@ -1,17 +0,0 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
ij_typescript_use_double_quotes = false
[*.md]
max_line_length = off
trim_trailing_whitespace = false

View File

@@ -1,42 +0,0 @@
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Node
/node_modules
npm-debug.log
yarn-error.log
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.history/*
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
# System files
.DS_Store
Thumbs.db

View File

@@ -1,4 +0,0 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
"recommendations": ["angular.ng-template"]
}

View File

@@ -1,20 +0,0 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "ng serve",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: start",
"url": "http://localhost:44200/",
},
{
"name": "ng test",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: test",
"url": "http://localhost:9876/debug.html"
}
]
}

View File

@@ -1,49 +0,0 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "start",
"options": {
"env": {
"PORT": "44200",
"services__Vegasco-Server-Api__https__0": "https://localhost:7098",
"NODE_ENV": "development"
}
},
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "(.*?)"
},
"endsPattern": {
"regexp": "bundle generation complete"
}
}
}
},
{
"type": "npm",
"script": "test",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "(.*?)"
},
"endsPattern": {
"regexp": "bundle generation complete"
}
}
}
}
]
}

View File

@@ -1,59 +0,0 @@
# VegascoWeb
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 20.0.1.
## Development server
To start a local development server, run:
```bash
ng serve
```
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
## Code scaffolding
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
```bash
ng generate component component-name
```
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
```bash
ng generate --help
```
## Building
To build the project run:
```bash
ng build
```
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
## Running unit tests
To execute unit tests with the [Karma](https://karma-runner.github.io) test runner, use the following command:
```bash
ng test
```
## Running end-to-end tests
For end-to-end (e2e) testing, run:
```bash
ng e2e
```
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
## Additional Resources
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.

View File

@@ -1,113 +0,0 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"Vegasco-Web": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "scss"
}
},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"browser": "src/main.ts",
"polyfills": [
"zone.js"
],
"tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"src/styles.scss"
]
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kB",
"maximumError": "1MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
}
],
"outputHashing": "all",
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.production.ts"
}
]
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true,
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.development.ts"
}
]
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular/build:dev-server",
"options": {
"proxyConfig": "proxy.config.js"
},
"configurations": {
"production": {
"buildTarget": "Vegasco-Web:build:production"
},
"development": {
"buildTarget": "Vegasco-Web:build:development"
}
},
"defaultConfiguration": "development"
},
"extract-i18n": {
"builder": "@angular/build:extract-i18n"
},
"test": {
"builder": "@angular/build:karma",
"options": {
"polyfills": [
"zone.js",
"zone.js/testing"
],
"tsConfig": "tsconfig.spec.json",
"inlineStyleLanguage": "scss",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"src/styles.scss"
]
}
}
}
}
}
}

View File

@@ -1,40 +0,0 @@
{
"name": "vegasco-web",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "run-script-os",
"start:win32": "ng serve --port %PORT% --configuration development",
"start:default": "ng serve --port $PORT --configuration development",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test"
},
"private": true,
"dependencies": {
"@angular/common": "^20.0.3",
"@angular/compiler": "^20.0.3",
"@angular/core": "^20.0.3",
"@angular/forms": "^20.0.3",
"@angular/platform-browser": "^20.0.3",
"@angular/router": "^20.0.3",
"keycloak-angular": "^19.0.2",
"rxjs": "~7.8.2",
"tslib": "^2.8.1",
"zone.js": "~0.15.1"
},
"devDependencies": {
"@angular/build": "^20.0.2",
"@angular/cli": "^20.0.2",
"@angular/compiler-cli": "^20.0.3",
"@types/jasmine": "~5.1.8",
"jasmine-core": "~5.7.1",
"karma": "~6.4.4",
"karma-chrome-launcher": "~3.2.0",
"karma-coverage": "~2.2.1",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0",
"run-script-os": "^1.1.6",
"typescript": "~5.8.3"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +0,0 @@
module.exports = {
"/api": {
target:
process.env["services__Vegasco-Server-Api__https__0"] ||
process.env["services__Vegasco-Server-Api__http__0"],
secure: process.env["NODE_ENV"] !== "development",
pathRewrite: {
"^/api": "",
},
},
};

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -1,8 +0,0 @@
interface ConsumptionEntry {
id: string;
dateTime: string;
distance: number;
amount: number;
ignoreInCalculation: boolean;
carId: string;
}

View File

@@ -1,3 +0,0 @@
interface GetConsumptionEntriesResponse {
consumptions: ConsumptionEntry[];
}

View File

@@ -1,17 +0,0 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZoneChangeDetection } from '@angular/core';
import { provideRouter, withComponentInputBinding } from '@angular/router';
import { routes } from './app.routes';
import {provideHttpClient, withInterceptors} from '@angular/common/http';
import { provideKeycloakAngular } from './auth/auth.config';
import { includeBearerTokenInterceptor } from 'keycloak-angular';
export const appConfig: ApplicationConfig = {
providers: [
provideKeycloakAngular(),
provideBrowserGlobalErrorListeners(),
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes, withComponentInputBinding()),
provideHttpClient(withInterceptors([includeBearerTokenInterceptor])),
]
};

View File

@@ -1,5 +0,0 @@
<main class="main">
<div class="content">
<router-outlet/>
</div>
</main>

View File

@@ -1,13 +0,0 @@
import { Routes } from '@angular/router';
export const routes: Routes = [
{
path: '',
redirectTo: 'entries',
pathMatch: 'full'
},
{
path: 'entries',
loadChildren: () => import('./modules/entries/entries.routes').then(m => m.routes)
}
];

View File

@@ -1,23 +0,0 @@
import { TestBed } from '@angular/core/testing';
import { App } from './app';
describe('App', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [App],
}).compileComponents();
});
it('should create the app', () => {
const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});
it('should render title', () => {
const fixture = TestBed.createComponent(App);
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, Vegasco-Web');
});
});

View File

@@ -1,12 +0,0 @@
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
@Component({
selector: 'app-root',
imports: [RouterOutlet],
templateUrl: './app.html',
styleUrl: './app.scss'
})
export class App {
protected title = 'Vegasco-Web';
}

View File

@@ -1,45 +0,0 @@
import { environment } from '../../environments/environment';
import {
provideKeycloak,
createInterceptorCondition,
IncludeBearerTokenCondition,
INCLUDE_BEARER_TOKEN_INTERCEPTOR_CONFIG,
withAutoRefreshToken,
AutoRefreshTokenService,
UserActivityService
} from 'keycloak-angular';
const serverHostBearerInterceptorCondition = createInterceptorCondition<IncludeBearerTokenCondition>({
// The API is consumed through a proxy running on the same origin as the application.
// This means that the interceptor should include the bearer token for requests to the same origin
// which includes requests starting to / which implicitly sends the request to the same origin.
urlPattern: new RegExp(`^(${window.origin}|/)`)
});
export const provideKeycloakAngular = () =>
provideKeycloak({
config: {
url: environment.keycloak.host,
realm: environment.keycloak.realm,
clientId: environment.keycloak.clientId,
},
initOptions: {
onLoad: 'login-required',
silentCheckSsoRedirectUri: window.location.origin + '/silent-check-sso.html',
redirectUri: window.location.origin + '/',
checkLoginIframe: false,
},
features: [
withAutoRefreshToken({
onInactivityTimeout: 'login',
})
],
providers: [
AutoRefreshTokenService,
UserActivityService,
{
provide: INCLUDE_BEARER_TOKEN_INTERCEPTOR_CONFIG,
useValue: [serverHostBearerInterceptorCondition]
}
]
});

View File

@@ -1,9 +0,0 @@
import { Routes } from "@angular/router";
import { EntriesComponent } from "./entries/entries.component";
export const routes: Routes = [
{
path: '',
component: EntriesComponent
}
];

View File

@@ -1,22 +0,0 @@
@if (consumptionEntries$ | async; as consumptionEntries) {
<div>
<table>
<thead>
<tr>
<th>Datum</th>
<th>Distanz</th>
<th>Menge</th>
</tr>
</thead>
<tbody>
@for (entry of consumptionEntries; track entry.id) {
<tr>
<td>{{ entry.dateTime | date }}</td>
<td>{{ entry.distance }} km</td>
<td>{{ entry.amount }} l</td>
</tr>
}
</tbody>
</table>
</div>
}

View File

@@ -1,3 +0,0 @@
th, td {
padding: 0.5rem;
}

View File

@@ -1,28 +0,0 @@
import { AsyncPipe, DatePipe } from '@angular/common';
import { HttpClient } from '@angular/common/http';
import { Component, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { map, Observable, tap } from 'rxjs';
@Component({
selector: 'app-entries',
imports: [AsyncPipe, DatePipe],
templateUrl: './entries.component.html',
styleUrl: './entries.component.scss'
})
export class EntriesComponent {
private readonly http = inject(HttpClient);
protected readonly consumptionEntries$: Observable<ConsumptionEntry[]>;
constructor() {
this.consumptionEntries$ = this.http.get<GetConsumptionEntriesResponse>('/api/v1/consumptions')
.pipe(
takeUntilDestroyed(),
tap((response) => {
console.log('Entries response:', response);
}),
map(response => response.consumptions)
);
}
}

View File

@@ -1,11 +0,0 @@
import { Environment } from "./environment.interface";
export const environment: Environment = {
name: "Dev",
isProduction: false,
keycloak: {
host: "https://login.nuyken.dev",
realm: "development",
clientId: "vegasco"
}
};

View File

@@ -1,17 +0,0 @@
/** The app's configuration based on the target environment */
export interface Environment {
/** A name for this configuration, e.g. 'Prod' */
name: string;
/** Whether this configuration is for production or not */
isProduction: boolean;
/** Keycloak login configuration */
keycloak: {
/** The host under which the keycloak is reachable */
host: string;
/** The keycloak realm in which the client lives */
realm: string;
/** The app's client id */
clientId: string;
}
}

View File

@@ -1,11 +0,0 @@
import { Environment } from "./environment.interface";
export const environment: Environment = {
name: "Prod",
isProduction: true,
keycloak: {
host: "https://login.nuyken.dev",
realm: "apps",
clientId: "vegasco"
}
};

View File

@@ -1,11 +0,0 @@
import { Environment } from "./environment.interface";
export const environment: Environment = {
name: "",
isProduction: false,
keycloak: {
host: "",
realm: "",
clientId: ""
}
};

View File

@@ -1,13 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>VegascoWeb</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
</head>
<body>
<app-root></app-root>
</body>
</html>

View File

@@ -1,6 +0,0 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { App } from './app/app';
bootstrapApplication(App, appConfig)
.catch((err) => console.error(err));

View File

@@ -1 +0,0 @@
/* You can add global styles to this file, and also import other style files */

View File

@@ -1,15 +0,0 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": []
},
"include": [
"src/**/*.ts"
],
"exclude": [
"src/**/*.spec.ts"
]
}

View File

@@ -1,34 +0,0 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"compileOnSave": false,
"compilerOptions": {
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"experimentalDecorators": true,
"importHelpers": true,
"target": "ES2022",
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"typeCheckHostBindings": true,
"strictTemplates": true
},
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

View File

@@ -1,14 +0,0 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/spec",
"types": [
"jasmine"
]
},
"include": [
"src/**/*.ts"
]
}

View File

@@ -1,3 +0,0 @@
using StronglyTypedIds;
[assembly: StronglyTypedIdDefaults(Template.Guid, "guid-efcore")]

View File

@@ -1,28 +0,0 @@
using FluentValidation;
namespace Vegasco.Server.Api.Authentication;
public class JwtOptions
{
public const string SectionName = "JWT";
public string ValidAudience { get; set; } = "";
public string MetadataUrl { get; set; } = "";
public string? NameClaimType { get; set; }
public bool AllowHttpMetadataUrl { get; set; }
}
public class JwtOptionsValidator : AbstractValidator<JwtOptions>
{
public JwtOptionsValidator()
{
RuleFor(x => x.ValidAudience)
.NotEmpty();
RuleFor(x => x.MetadataUrl)
.NotEmpty();
}
}

View File

@@ -1,78 +0,0 @@
using Microsoft.Extensions.Options;
using System.Diagnostics.CodeAnalysis;
using System.Security.Claims;
namespace Vegasco.Server.Api.Authentication;
public sealed class UserAccessor
{
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly IOptions<JwtOptions> _jwtOptions;
/// <summary>
/// Stores the username upon first retrieval
/// </summary>
private string? _cachedUsername;
/// <summary>
/// Stores the id upon first retrieval
/// </summary>
private string? _cachedId;
public UserAccessor(IHttpContextAccessor httpContextAccessor, IOptions<JwtOptions> jwtOptions)
{
_httpContextAccessor = httpContextAccessor;
_jwtOptions = jwtOptions;
}
public string GetUsername()
{
if (string.IsNullOrEmpty(_cachedUsername))
{
_cachedUsername = GetClaimValue(_jwtOptions.Value.NameClaimType ?? ClaimTypes.Name);
}
return _cachedUsername;
}
public string GetUserId()
{
if (string.IsNullOrEmpty(_cachedId))
{
_cachedId = GetClaimValue(ClaimTypes.NameIdentifier);
}
return _cachedId;
}
private string GetClaimValue(string claimType)
{
HttpContext? httpContext = _httpContextAccessor.HttpContext;
if (httpContext is null)
{
ThrowForMissingHttpContext();
}
string? claimValue = httpContext.User.FindFirstValue(claimType);
if (string.IsNullOrWhiteSpace(claimValue))
{
ThrowForMissingClaim(claimType);
}
return claimValue;
}
[DoesNotReturn]
private static void ThrowForMissingHttpContext()
{
throw new InvalidOperationException("No HttpContext available.");
}
[DoesNotReturn]
private static void ThrowForMissingClaim(string claimType)
{
throw new InvalidOperationException($"No claim of type '{claimType}' found on the current user.");
}
}

View File

@@ -1,42 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Vegasco.Server.Api.Consumptions;
using Vegasco.Server.Api.Users;
namespace Vegasco.Server.Api.Cars;
public class Car
{
public CarId Id { get; set; } = CarId.New();
public string Name { get; set; } = "";
public string UserId { get; set; } = "";
public virtual User User { get; set; } = null!;
public virtual ICollection<Consumption> Consumptions { get; set; } = [];
}
public class CarTableConfiguration : IEntityTypeConfiguration<Car>
{
public const int NameMaxLength = 50;
public void Configure(EntityTypeBuilder<Car> builder)
{
builder.HasKey(x => x.Id);
builder.Property(x => x.Id)
.HasConversion<CarId.EfCoreValueConverter>();
builder.Property(x => x.Name)
.IsRequired()
.HasMaxLength(NameMaxLength);
builder.Property(x => x.UserId)
.IsRequired();
builder.HasOne(x => x.User)
.WithMany(x => x.Cars);
}
}

View File

@@ -1,6 +0,0 @@
using StronglyTypedIds;
namespace Vegasco.Server.Api.Cars;
[StronglyTypedId]
public partial struct CarId;

View File

@@ -1,69 +0,0 @@
using FluentValidation;
using FluentValidation.Results;
using Vegasco.Server.Api.Authentication;
using Vegasco.Server.Api.Common;
using Vegasco.Server.Api.Persistence;
using Vegasco.Server.Api.Users;
namespace Vegasco.Server.Api.Cars;
public static class CreateCar
{
public record Request(string Name);
public record Response(Guid Id, string Name);
public static RouteHandlerBuilder MapEndpoint(IEndpointRouteBuilder builder)
{
return builder
.MapPost("cars", Endpoint)
.WithTags("Cars");
}
public class Validator : AbstractValidator<Request>
{
public Validator()
{
RuleFor(x => x.Name)
.NotEmpty()
.MaximumLength(CarTableConfiguration.NameMaxLength);
}
}
public static async Task<IResult> Endpoint(
Request request,
IEnumerable<IValidator<Request>> validators,
ApplicationDbContext dbContext,
UserAccessor userAccessor,
CancellationToken cancellationToken)
{
List<ValidationResult> failedValidations = await validators.ValidateAllAsync(request, cancellationToken: cancellationToken);
if (failedValidations.Count > 0)
{
return TypedResults.BadRequest(new HttpValidationProblemDetails(failedValidations.ToCombinedDictionary()));
}
string userId = userAccessor.GetUserId();
User? user = await dbContext.Users.FindAsync([userId], cancellationToken: cancellationToken);
if (user is null)
{
user = new User
{
Id = userId
};
await dbContext.Users.AddAsync(user, cancellationToken);
}
Car car = new()
{
Name = request.Name,
UserId = userId
};
await dbContext.Cars.AddAsync(car, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
Response response = new(car.Id.Value, car.Name);
return TypedResults.Created($"/v1/cars/{car.Id}", response);
}
}

View File

@@ -1,31 +0,0 @@
using Vegasco.Server.Api.Persistence;
namespace Vegasco.Server.Api.Cars;
public static class DeleteCar
{
public static RouteHandlerBuilder MapEndpoint(IEndpointRouteBuilder builder)
{
return builder
.MapDelete("cars/{id:guid}", Endpoint)
.WithTags("Cars");
}
public static async Task<IResult> Endpoint(
Guid id,
ApplicationDbContext dbContext,
CancellationToken cancellationToken)
{
Car? car = await dbContext.Cars.FindAsync([new CarId(id)], cancellationToken: cancellationToken);
if (car is null)
{
return TypedResults.NotFound();
}
dbContext.Cars.Remove(car);
await dbContext.SaveChangesAsync(cancellationToken);
return TypedResults.NoContent();
}
}

View File

@@ -1,31 +0,0 @@
using Vegasco.Server.Api.Persistence;
namespace Vegasco.Server.Api.Cars;
public static class GetCar
{
public record Response(Guid Id, string Name);
public static RouteHandlerBuilder MapEndpoint(IEndpointRouteBuilder builder)
{
return builder
.MapGet("cars/{id:guid}", Endpoint)
.WithTags("Cars");
}
private static async Task<IResult> Endpoint(
Guid id,
ApplicationDbContext dbContext,
CancellationToken cancellationToken)
{
Car? car = await dbContext.Cars.FindAsync([new CarId(id)], cancellationToken: cancellationToken);
if (car is null)
{
return TypedResults.NotFound();
}
var response = new Response(car.Id.Value, car.Name);
return TypedResults.Ok(response);
}
}

View File

@@ -1,46 +0,0 @@
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Vegasco.Server.Api.Persistence;
namespace Vegasco.Server.Api.Cars;
public static class GetCars
{
public class ApiResponse
{
public IEnumerable<ResponseDto> Cars { get; set; } = [];
}
public record ResponseDto(Guid Id, string Name);
public class Request
{
[FromQuery(Name = "page")] public int? Page { get; set; }
[FromQuery(Name = "pageSize")] public int? PageSize { get; set; }
}
public static RouteHandlerBuilder MapEndpoint(IEndpointRouteBuilder builder)
{
return builder
.MapGet("cars", Endpoint)
.WithDescription("Returns all cars")
.WithTags("Cars");
}
private static async Task<Ok<ApiResponse>> Endpoint(
[AsParameters] Request request,
ApplicationDbContext dbContext,
CancellationToken cancellationToken)
{
List<ResponseDto> cars = await dbContext.Cars
.Select(x => new ResponseDto(x.Id.Value, x.Name))
.ToListAsync(cancellationToken);
var response = new ApiResponse
{
Cars = cars
};
return TypedResults.Ok(response);
}
}

View File

@@ -1,58 +0,0 @@
using FluentValidation;
using FluentValidation.Results;
using Vegasco.Server.Api.Authentication;
using Vegasco.Server.Api.Common;
using Vegasco.Server.Api.Persistence;
namespace Vegasco.Server.Api.Cars;
public static class UpdateCar
{
public record Request(string Name);
public record Response(Guid Id, string Name);
public static RouteHandlerBuilder MapEndpoint(IEndpointRouteBuilder builder)
{
return builder
.MapPut("cars/{id:guid}", Endpoint)
.WithTags("Cars");
}
public class Validator : AbstractValidator<Request>
{
public Validator()
{
RuleFor(x => x.Name)
.NotEmpty()
.MaximumLength(CarTableConfiguration.NameMaxLength);
}
}
public static async Task<IResult> Endpoint(
Guid id,
Request request,
IEnumerable<IValidator<Request>> validators,
ApplicationDbContext dbContext,
UserAccessor userAccessor,
CancellationToken cancellationToken)
{
List<ValidationResult> failedValidations = await validators.ValidateAllAsync(request, cancellationToken);
if (failedValidations.Count > 0)
{
return TypedResults.BadRequest(new HttpValidationProblemDetails(failedValidations.ToCombinedDictionary()));
}
Car? car = await dbContext.Cars.FindAsync([new CarId(id)], cancellationToken: cancellationToken);
if (car is null)
{
return TypedResults.NotFound();
}
car.Name = request.Name;
await dbContext.SaveChangesAsync(cancellationToken);
Response response = new(car.Id.Value, car.Name);
return TypedResults.Ok(response);
}
}

View File

@@ -1,9 +0,0 @@
namespace Vegasco.Server.Api.Common;
public static class Constants
{
public static class Authorization
{
public const string RequireAuthenticatedUserPolicy = "RequireAuthenticatedUser";
}
}

View File

@@ -1,157 +0,0 @@
using Asp.Versioning;
using FluentValidation;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.Options;
using System.Diagnostics;
using System.Reflection;
using Vegasco.Server.Api.Authentication;
using Vegasco.Server.Api.Common;
using Vegasco.Server.Api.Persistence;
namespace Vegasco.Server.Api.Common;
public static class DependencyInjectionExtensions
{
/// <summary>
/// Adds all the Api related services to the Dependency Injection container.
/// </summary>
/// <param name="builder"></param>
public static void AddApiServices(this IHostApplicationBuilder builder)
{
builder.Services
.AddMiscellaneousServices()
.AddCustomOpenApi()
.AddApiVersioning()
.AddAuthenticationAndAuthorization(builder.Environment);
builder.AddDbContext();
}
private static IServiceCollection AddMiscellaneousServices(this IServiceCollection services)
{
services.AddSingleton(() =>
{
string assemblyName = Assembly.GetExecutingAssembly()
.GetName()
.Name ?? "Vegasco.Server.Api";
return new ActivitySource(assemblyName);
});
services.AddResponseCompression();
services.AddValidatorsFromAssemblies(
[
typeof(IApiMarker).Assembly
], ServiceLifetime.Singleton);
services.AddHealthChecks();
services.AddHttpContextAccessor();
services.AddHostedService<ApplyMigrationsService>();
services.AddRequestLocalization(o =>
{
string[] cultures =
[
"en-US",
"en",
"de-DE",
"de"
];
o.SetDefaultCulture(cultures[0])
.AddSupportedCultures(cultures)
.AddSupportedUICultures(cultures);
});
return services;
}
private static IServiceCollection AddCustomOpenApi(this IServiceCollection services)
{
services.AddEndpointsApiExplorer();
services.AddOpenApi(o =>
{
o.CreateSchemaReferenceId = jsonTypeInfo =>
{
if (string.IsNullOrEmpty(jsonTypeInfo.Type.FullName))
{
return jsonTypeInfo.Type.Name;
}
string? fullClassName = jsonTypeInfo.Type.FullName;
if (!string.IsNullOrEmpty(jsonTypeInfo.Type.Namespace))
{
fullClassName = fullClassName
.Replace(jsonTypeInfo.Type.Namespace, "")
.TrimStart('.');
}
fullClassName = fullClassName.Replace('+', '_');
return fullClassName;
};
});
return services;
}
private static IServiceCollection AddApiVersioning(this IServiceCollection services)
{
services.AddApiVersioning(o =>
{
o.DefaultApiVersion = new ApiVersion(1);
o.ApiVersionReader = new UrlSegmentApiVersionReader();
o.ReportApiVersions = true;
})
.AddApiExplorer(o =>
{
o.GroupNameFormat = "'v'V";
o.SubstituteApiVersionInUrl = true;
});
return services;
}
private static IServiceCollection AddAuthenticationAndAuthorization(this IServiceCollection services, IHostEnvironment environment)
{
services.AddOptions<JwtOptions>()
.BindConfiguration(JwtOptions.SectionName)
.ValidateFluently()
.ValidateOnStart();
var jwtOptions = services.BuildServiceProvider().GetRequiredService<IOptions<JwtOptions>>();
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, o =>
{
o.MetadataAddress = jwtOptions.Value.MetadataUrl;
o.TokenValidationParameters.ValidAudience = jwtOptions.Value.ValidAudience;
o.TokenValidationParameters.ValidateAudience = true;
if (!string.IsNullOrWhiteSpace(jwtOptions.Value.NameClaimType))
{
o.TokenValidationParameters.NameClaimType = jwtOptions.Value.NameClaimType;
}
o.RequireHttpsMetadata = !jwtOptions.Value.AllowHttpMetadataUrl && !environment.IsDevelopment();
});
services.AddAuthorizationBuilder()
.AddPolicy(Constants.Authorization.RequireAuthenticatedUserPolicy, p => p
.RequireAuthenticatedUser()
.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme));
services.AddScoped<UserAccessor>();
return services;
}
private static IHostApplicationBuilder AddDbContext(this IHostApplicationBuilder builder)
{
builder.AddNpgsqlDbContext<ApplicationDbContext>(AppHost.Shared.Constants.Database.Name);
return builder;
}
}

View File

@@ -1,37 +0,0 @@
using FluentValidation;
using FluentValidation.Results;
using Microsoft.Extensions.Options;
namespace Vegasco.Server.Api.Common;
public class FluentValidationOptions<TOptions> : IValidateOptions<TOptions>
where TOptions : class
{
private readonly IEnumerable<IValidator<TOptions>> _validators;
public string? Name { get; set; }
public FluentValidationOptions(string? name, IEnumerable<IValidator<TOptions>> validators)
{
Name = name;
_validators = validators;
}
public ValidateOptionsResult Validate(string? name, TOptions options)
{
if (name is not null && name != Name)
{
return ValidateOptionsResult.Skip;
}
ArgumentNullException.ThrowIfNull(options);
List<ValidationResult> failedValidations = _validators.ValidateAllAsync(options).Result;
if (failedValidations.Count == 0)
{
return ValidateOptionsResult.Success;
}
return ValidateOptionsResult.Fail(failedValidations.SelectMany(x => x.Errors.Select(x => x.ErrorMessage)));
}
}

View File

@@ -1,3 +0,0 @@
namespace Vegasco.Server.Api.Common;
public interface IApiMarker;

View File

@@ -1,55 +0,0 @@
using Asp.Versioning.ApiExplorer;
using Vegasco.Server.Api.Endpoints;
using Vegasco.Server.ServiceDefaults;
namespace Vegasco.Server.Api.Common;
internal static class StartupExtensions
{
internal static WebApplication ConfigureServices(this WebApplicationBuilder builder)
{
builder.AddServiceDefaults();
builder.Configuration.AddEnvironmentVariables("Vegasco_");
builder.AddApiServices();
WebApplication app = builder.Build();
return app;
}
internal static WebApplication ConfigureRequestPipeline(this WebApplication app)
{
app.UseRequestLocalization(o =>
{
o.ApplyCurrentCultureToResponseHeaders = true;
});
app.UseHttpsRedirection();
app.MapHealthChecks("/health");
app.UseAuthentication();
app.UseAuthorization();
app.MapEndpoints();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi("/swagger/{documentName}/swagger.json");
app.UseSwaggerUI(o =>
{
// Create a Swagger endpoint for each API version
IReadOnlyList<ApiVersionDescription> apiVersions = app.DescribeApiVersions();
foreach (ApiVersionDescription apiVersionDescription in apiVersions)
{
string url = $"/swagger/{apiVersionDescription.GroupName}/swagger.json";
string name = apiVersionDescription.GroupName.ToUpperInvariant();
o.SwaggerEndpoint(url, name);
}
});
}
return app;
}
}

View File

@@ -1,62 +0,0 @@
using FluentValidation;
using FluentValidation.Results;
using Microsoft.Extensions.Options;
namespace Vegasco.Server.Api.Common;
public static class ValidatorExtensions
{
/// <summary>
/// Asynchronously validates an instance of <typeparamref name="T"/> against all <see cref="IValidator{T}"/> instances in <paramref name="validators"/>.
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="validators"></param>
/// <param name="instance"></param>
/// <returns>The failed validation results.</returns>
public static async Task<List<ValidationResult>> ValidateAllAsync<T>(this IEnumerable<IValidator<T>> validators, T instance, CancellationToken cancellationToken = default)
{
List<Task<ValidationResult>> validationTasks = validators
.Select(validator => validator.ValidateAsync(instance, cancellationToken))
.ToList();
await Task.WhenAll(validationTasks);
List<ValidationResult> failedValidations = validationTasks
.Select(x => x.Result)
.Where(x => !x.IsValid)
.ToList();
return failedValidations;
}
public static Dictionary<string, string[]> ToCombinedDictionary(this IEnumerable<ValidationResult> validationResults)
{
// Use a hash set to avoid duplicate error messages.
Dictionary<string, HashSet<string>> combinedErrors = [];
foreach (ValidationFailure? error in validationResults.SelectMany(x => x.Errors))
{
if (!combinedErrors.TryGetValue(error.PropertyName, out HashSet<string>? value))
{
value = [error.ErrorMessage];
combinedErrors[error.PropertyName] = value;
continue;
}
value.Add(error.ErrorMessage);
}
return combinedErrors.ToDictionary(x => x.Key, x => x.Value.ToArray());
}
public static OptionsBuilder<T> ValidateFluently<T>(this OptionsBuilder<T> builder)
where T : class
{
builder.Services.AddTransient<IValidateOptions<T>>(serviceProvider =>
{
IEnumerable<IValidator<T>> validators = serviceProvider.GetServices<IValidator<T>>() ?? [];
return new FluentValidationOptions<T>(builder.Name, validators);
});
return builder;
}
}

View File

@@ -1,52 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Vegasco.Server.Api.Cars;
namespace Vegasco.Server.Api.Consumptions;
public class Consumption
{
public ConsumptionId Id { get; set; } = ConsumptionId.New();
public DateTimeOffset DateTime { get; set; }
public double Distance { get; set; }
public double Amount { get; set; }
public bool IgnoreInCalculation { get; set; }
public CarId CarId { get; set; }
public virtual Car Car { get; set; } = null!;
}
public class ConsumptionTableConfiguration : IEntityTypeConfiguration<Consumption>
{
public void Configure(EntityTypeBuilder<Consumption> builder)
{
builder.HasKey(x => x.Id);
builder.Property(x => x.Id)
.HasConversion<ConsumptionId.EfCoreValueConverter>();
builder.Property(x => x.DateTime)
.IsRequired();
builder.Property(x => x.Distance)
.IsRequired();
builder.Property(x => x.Amount)
.IsRequired();
builder.Property(x => x.IgnoreInCalculation)
.IsRequired();
builder.Property(x => x.CarId)
.IsRequired()
.HasConversion<CarId.EfCoreValueConverter>();
builder.HasOne(x => x.Car)
.WithMany(x => x.Consumptions);
}
}

View File

@@ -1,7 +0,0 @@
using StronglyTypedIds;
namespace Vegasco.Server.Api.Consumptions;
[StronglyTypedId]
public partial struct ConsumptionId;

View File

@@ -1,74 +0,0 @@
using FluentValidation;
using FluentValidation.Results;
using Vegasco.Server.Api.Cars;
using Vegasco.Server.Api.Common;
using Vegasco.Server.Api.Persistence;
namespace Vegasco.Server.Api.Consumptions;
public static class CreateConsumption
{
public record Request(DateTimeOffset DateTime, double Distance, double Amount, bool IgnoreInCalculation, Guid CarId);
public record Response(Guid Id, DateTimeOffset DateTime, double Distance, double Amount, bool IgnoreInCalculation, Guid CarId);
public static RouteHandlerBuilder MapEndpoint(IEndpointRouteBuilder builder)
{
return builder
.MapPost("consumptions", Endpoint)
.WithTags("Consumptions");
}
public class Validator : AbstractValidator<Request>
{
public Validator(TimeProvider timeProvider)
{
RuleFor(x => x.DateTime.ToUniversalTime())
.LessThanOrEqualTo(timeProvider.GetUtcNow())
.WithName(nameof(Request.DateTime));
RuleFor(x => x.Distance)
.GreaterThan(0);
RuleFor(x => x.Amount)
.GreaterThan(0);
RuleFor(x => x.CarId)
.NotEmpty();
}
}
private static async Task<IResult> Endpoint(
ApplicationDbContext dbContext,
Request request,
IEnumerable<IValidator<Request>> validators,
CancellationToken cancellationToken)
{
List<ValidationResult> failedValidations = await validators.ValidateAllAsync(request, cancellationToken);
if (failedValidations.Count > 0)
{
return TypedResults.BadRequest(new HttpValidationProblemDetails(failedValidations.ToCombinedDictionary()));
}
Car? car = await dbContext.Cars.FindAsync([new CarId(request.CarId)], cancellationToken);
if (car is null)
{
return TypedResults.NotFound();
}
var consumption = new Consumption
{
DateTime = request.DateTime.ToUniversalTime(),
Distance = request.Distance,
Amount = request.Amount,
IgnoreInCalculation = request.IgnoreInCalculation,
CarId = new CarId(request.CarId)
};
dbContext.Consumptions.Add(consumption);
await dbContext.SaveChangesAsync(cancellationToken);
return TypedResults.Created($"consumptions/{consumption.Id.Value}",
new Response(consumption.Id.Value, consumption.DateTime, consumption.Distance, consumption.Amount, consumption.IgnoreInCalculation, consumption.CarId.Value));
}
}

View File

@@ -1,30 +0,0 @@
using Vegasco.Server.Api.Persistence;
namespace Vegasco.Server.Api.Consumptions;
public static class DeleteConsumption
{
public static RouteHandlerBuilder MapEndpoint(IEndpointRouteBuilder builder)
{
return builder
.MapDelete("consumptions/{id:guid}", Endpoint)
.WithTags("Consumptions");
}
private static async Task<IResult> Endpoint(
ApplicationDbContext dbContext,
Guid id,
CancellationToken cancellationToken)
{
Consumption? consumption = await dbContext.Consumptions.FindAsync([new ConsumptionId(id)], cancellationToken);
if (consumption is null)
{
return TypedResults.NotFound();
}
dbContext.Consumptions.Remove(consumption);
await dbContext.SaveChangesAsync(cancellationToken);
return TypedResults.NoContent();
}
}

View File

@@ -1,32 +0,0 @@
using Vegasco.Server.Api.Persistence;
namespace Vegasco.Server.Api.Consumptions;
public static class GetConsumption
{
public record Response(Guid Id, DateTimeOffset DateTime, double Distance, double Amount, bool IgnoreInCalculation, Guid CarId);
public static RouteHandlerBuilder MapEndpoint(IEndpointRouteBuilder builder)
{
return builder
.MapGet("consumptions/{id:guid}", Endpoint)
.WithTags("Consumptions");
}
private static async Task<IResult> Endpoint(
ApplicationDbContext dbContext,
Guid id,
CancellationToken cancellationToken)
{
Consumption? consumption = await dbContext.Consumptions.FindAsync([new ConsumptionId(id)], cancellationToken);
if (consumption is null)
{
return TypedResults.NotFound();
}
var response = new Response(consumption.Id.Value, consumption.DateTime, consumption.Distance,
consumption.Amount, consumption.IgnoreInCalculation, consumption.CarId.Value);
return TypedResults.Ok(response);
}
}

View File

@@ -1,53 +0,0 @@
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Vegasco.Server.Api.Persistence;
namespace Vegasco.Server.Api.Consumptions;
public static class GetConsumptions
{
public class ApiResponse
{
public IEnumerable<ResponseDto> Consumptions { get; set; } = [];
}
public record ResponseDto(
Guid Id,
DateTimeOffset DateTime,
double Distance,
double Amount,
bool IgnoreInCalculation,
Guid CarId);
public class Request
{
[FromQuery(Name = "page")] public int? Page { get; set; }
[FromQuery(Name = "pageSize")] public int? PageSize { get; set; }
}
public static RouteHandlerBuilder MapEndpoint(IEndpointRouteBuilder builder)
{
return builder
.MapGet("consumptions", Endpoint)
.WithDescription("Returns all consumption entries")
.WithTags("Consumptions");
}
private static async Task<Ok<ApiResponse>> Endpoint(
[AsParameters] Request request,
ApplicationDbContext dbContext,
CancellationToken cancellationToken)
{
List<ResponseDto> consumptions = await dbContext.Consumptions
.Select(x =>
new ResponseDto(x.Id.Value, x.DateTime, x.Distance, x.Amount, x.IgnoreInCalculation, x.CarId.Value))
.ToListAsync(cancellationToken);
var apiResponse = new ApiResponse
{
Consumptions = consumptions
};
return TypedResults.Ok(apiResponse);
}
}

View File

@@ -1,65 +0,0 @@
using FluentValidation;
using FluentValidation.Results;
using Vegasco.Server.Api.Common;
using Vegasco.Server.Api.Persistence;
namespace Vegasco.Server.Api.Consumptions;
public static class UpdateConsumption
{
public record Request(DateTimeOffset DateTime, double Distance, double Amount, bool IgnoreInCalculation);
public record Response(Guid Id, DateTimeOffset DateTime, double Distance, double Amount, bool IgnoreInCalculation, Guid CarId);
public static RouteHandlerBuilder MapEndpoint(IEndpointRouteBuilder builder)
{
return builder
.MapPut("consumptions/{id:guid}", Endpoint)
.WithTags("Consumptions");
}
public class Validator : AbstractValidator<Request>
{
public Validator(TimeProvider timeProvider)
{
RuleFor(x => x.DateTime.ToUniversalTime())
.LessThanOrEqualTo(timeProvider.GetUtcNow())
.WithName(nameof(Request.DateTime));
RuleFor(x => x.Distance)
.GreaterThan(0);
RuleFor(x => x.Amount)
.GreaterThan(0);
}
}
private static async Task<IResult> Endpoint(
ApplicationDbContext dbContext,
Guid id,
Request request,
IEnumerable<IValidator<Request>> validators,
CancellationToken cancellationToken)
{
List<ValidationResult> failedValidations = await validators.ValidateAllAsync(request, cancellationToken);
if (failedValidations.Count > 0)
{
return TypedResults.BadRequest(new HttpValidationProblemDetails(failedValidations.ToCombinedDictionary()));
}
Consumption? consumption = await dbContext.Consumptions.FindAsync([new ConsumptionId(id)], cancellationToken);
if (consumption is null)
{
return TypedResults.NotFound();
}
consumption.DateTime = request.DateTime.ToUniversalTime();
consumption.Distance = request.Distance;
consumption.Amount = request.Amount;
consumption.IgnoreInCalculation = request.IgnoreInCalculation;
await dbContext.SaveChangesAsync(cancellationToken);
return TypedResults.Ok(new Response(consumption.Id.Value, consumption.DateTime, consumption.Distance, consumption.Amount, consumption.IgnoreInCalculation, consumption.CarId.Value));
}
}

View File

@@ -1,45 +0,0 @@
using Asp.Versioning.Builder;
using Asp.Versioning.Conventions;
using Vegasco.Server.Api.Cars;
using Vegasco.Server.Api.Common;
using Vegasco.Server.Api.Consumptions;
using Vegasco.Server.Api.Info;
namespace Vegasco.Server.Api.Endpoints;
public static class EndpointExtensions
{
public static void MapEndpoints(this IEndpointRouteBuilder builder)
{
ApiVersionSet apiVersionSet = builder.NewApiVersionSet()
.HasApiVersion(1.0)
.Build();
RouteGroupBuilder versionedApis = builder.MapGroup("/v{apiVersion:apiVersion}")
.WithApiVersionSet(apiVersionSet);
GetCar.MapEndpoint(versionedApis)
.RequireAuthorization(Constants.Authorization.RequireAuthenticatedUserPolicy);
GetCars.MapEndpoint(versionedApis)
.RequireAuthorization(Constants.Authorization.RequireAuthenticatedUserPolicy);
CreateCar.MapEndpoint(versionedApis)
.RequireAuthorization(Constants.Authorization.RequireAuthenticatedUserPolicy);
UpdateCar.MapEndpoint(versionedApis)
.RequireAuthorization(Constants.Authorization.RequireAuthenticatedUserPolicy);
DeleteCar.MapEndpoint(versionedApis)
.RequireAuthorization(Constants.Authorization.RequireAuthenticatedUserPolicy);
GetConsumptions.MapEndpoint(versionedApis)
.RequireAuthorization(Constants.Authorization.RequireAuthenticatedUserPolicy);
GetConsumption.MapEndpoint(versionedApis)
.RequireAuthorization(Constants.Authorization.RequireAuthenticatedUserPolicy);
CreateConsumption.MapEndpoint(versionedApis)
.RequireAuthorization(Constants.Authorization.RequireAuthenticatedUserPolicy);
UpdateConsumption.MapEndpoint(versionedApis)
.RequireAuthorization(Constants.Authorization.RequireAuthenticatedUserPolicy);
DeleteConsumption.MapEndpoint(versionedApis)
.RequireAuthorization(Constants.Authorization.RequireAuthenticatedUserPolicy);
GetServerInfo.MapEndpoint(versionedApis);
}
}

View File

@@ -1,29 +0,0 @@
using Microsoft.AspNetCore.Http.HttpResults;
namespace Vegasco.Server.Api.Info;
public class GetServerInfo
{
public record Response(
string FullVersion,
string CommitId,
DateTimeOffset CommitDate,
string Environment);
public static RouteHandlerBuilder MapEndpoint(IEndpointRouteBuilder builder)
{
return builder
.MapGet("info/server", Endpoint)
.WithTags("Info");
}
private static Ok<Response> Endpoint(
IHostEnvironment environment)
{
return TypedResults.Ok(new Response(
ThisAssembly.AssemblyInformationalVersion,
ThisAssembly.GitCommitId,
new DateTimeOffset(ThisAssembly.GitCommitDate, TimeSpan.Zero),
environment.EnvironmentName));
}
}

View File

@@ -1,22 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Vegasco.Server.Api.Cars;
using Vegasco.Server.Api.Common;
using Vegasco.Server.Api.Consumptions;
using Vegasco.Server.Api.Users;
namespace Vegasco.Server.Api.Persistence;
public class ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) : DbContext(options)
{
public DbSet<Car> Cars { get; set; }
public DbSet<User> Users { get; set; }
public DbSet<Consumption> Consumptions { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.ApplyConfigurationsFromAssembly(typeof(IApiMarker).Assembly);
}
}

View File

@@ -1,24 +0,0 @@
using Microsoft.EntityFrameworkCore;
using System.Diagnostics;
namespace Vegasco.Server.Api.Persistence;
public class ApplyMigrationsService(
ILogger<ApplyMigrationsService> logger,
IServiceScopeFactory scopeFactory,
ActivitySource activitySource)
: IHostedService
{
public async Task StartAsync(CancellationToken cancellationToken)
{
using var activity = activitySource.StartActivity("ApplyMigrations");
logger.LogInformation("Starting migrations");
using IServiceScope scope = scopeFactory.CreateScope();
await using var dbContext = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
await dbContext.Database.MigrateAsync(cancellationToken);
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}

View File

@@ -1,121 +0,0 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Vegasco.Server.Api.Persistence;
#nullable disable
namespace Vegasco.Server.Api.Persistence.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20240818105918_Initial")]
partial class Initial
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Vegasco.Server.Api.Cars.Car", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("Cars");
});
modelBuilder.Entity("Vegasco.Server.Api.Consumptions.Consumption", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<double>("Amount")
.HasColumnType("double precision");
b.Property<Guid>("CarId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("DateTime")
.HasColumnType("timestamp with time zone");
b.Property<double>("Distance")
.HasColumnType("double precision");
b.Property<bool>("IgnoreInCalculation")
.HasColumnType("boolean");
b.HasKey("Id");
b.HasIndex("CarId");
b.ToTable("Consumptions");
});
modelBuilder.Entity("Vegasco.Server.Api.Users.User", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Users");
});
modelBuilder.Entity("Vegasco.Server.Api.Cars.Car", b =>
{
b.HasOne("Vegasco.Server.Api.Users.User", "User")
.WithMany("Cars")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Vegasco.Server.Api.Consumptions.Consumption", b =>
{
b.HasOne("Vegasco.Server.Api.Cars.Car", "Car")
.WithMany("Consumptions")
.HasForeignKey("CarId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Car");
});
modelBuilder.Entity("Vegasco.Server.Api.Cars.Car", b =>
{
b.Navigation("Consumptions");
});
modelBuilder.Entity("Vegasco.Server.Api.Users.User", b =>
{
b.Navigation("Cars");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -1,89 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Vegasco.Server.Api.Persistence.Migrations
{
/// <inheritdoc />
public partial class Initial : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Users",
columns: table => new
{
Id = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Users", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Cars",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
UserId = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Cars", x => x.Id);
table.ForeignKey(
name: "FK_Cars_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Consumptions",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
DateTime = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
Distance = table.Column<double>(type: "double precision", nullable: false),
Amount = table.Column<double>(type: "double precision", nullable: false),
IgnoreInCalculation = table.Column<bool>(type: "boolean", nullable: false),
CarId = table.Column<Guid>(type: "uuid", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Consumptions", x => x.Id);
table.ForeignKey(
name: "FK_Consumptions_Cars_CarId",
column: x => x.CarId,
principalTable: "Cars",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Cars_UserId",
table: "Cars",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_Consumptions_CarId",
table: "Consumptions",
column: "CarId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Consumptions");
migrationBuilder.DropTable(
name: "Cars");
migrationBuilder.DropTable(
name: "Users");
}
}
}

View File

@@ -1,118 +0,0 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Vegasco.Server.Api.Persistence;
#nullable disable
namespace Vegasco.Server.Api.Persistence.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
partial class ApplicationDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Vegasco.Server.Api.Cars.Car", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("Cars");
});
modelBuilder.Entity("Vegasco.Server.Api.Consumptions.Consumption", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<double>("Amount")
.HasColumnType("double precision");
b.Property<Guid>("CarId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("DateTime")
.HasColumnType("timestamp with time zone");
b.Property<double>("Distance")
.HasColumnType("double precision");
b.Property<bool>("IgnoreInCalculation")
.HasColumnType("boolean");
b.HasKey("Id");
b.HasIndex("CarId");
b.ToTable("Consumptions");
});
modelBuilder.Entity("Vegasco.Server.Api.Users.User", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Users");
});
modelBuilder.Entity("Vegasco.Server.Api.Cars.Car", b =>
{
b.HasOne("Vegasco.Server.Api.Users.User", "User")
.WithMany("Cars")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Vegasco.Server.Api.Consumptions.Consumption", b =>
{
b.HasOne("Vegasco.Server.Api.Cars.Car", "Car")
.WithMany("Consumptions")
.HasForeignKey("CarId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Car");
});
modelBuilder.Entity("Vegasco.Server.Api.Cars.Car", b =>
{
b.Navigation("Consumptions");
});
modelBuilder.Entity("Vegasco.Server.Api.Users.User", b =>
{
b.Navigation("Cars");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -1,6 +0,0 @@
using Vegasco.Server.Api.Common;
WebApplication.CreateBuilder(args)
.ConfigureServices()
.ConfigureRequestPipeline()
.Run();

View File

@@ -1,15 +0,0 @@
{
"profiles": {
"https": {
"commandName": "Project",
"launchBrowser": true,
"launchUrl": "swagger",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"dotnetRunMessages": true,
"applicationUrl": "https://localhost:7098;http://localhost:5076"
}
},
"$schema": "http://json.schemastore.org/launchsettings.json"
}

View File

@@ -1,10 +0,0 @@
using Vegasco.Server.Api.Cars;
namespace Vegasco.Server.Api.Users;
public class User
{
public string Id { get; set; } = "";
public virtual IList<Car> Cars { get; set; } = [];
}

View File

@@ -1,12 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Vegasco.Server.Api.Users;
public class UserTableConfiguration : IEntityTypeConfiguration<User>
{
public void Configure(EntityTypeBuilder<User> builder)
{
builder.HasKey(user => user.Id);
}
}

View File

@@ -1,46 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>4bf893d3-0c16-41ec-8b46-2768d841215d</UserSecretsId>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<DockerfileContext>..\..</DockerfileContext>
<RootNamespace>Vegasco.Server.Api</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Asp.Versioning.Http" Version="8.1.0" />
<PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.0" />
<PackageReference Include="Aspire.Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.3.0" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.5" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.5" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.5" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.5">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.21.2" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
<PackageReference Include="OpenTelemetry" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0" />
<PackageReference Include="StronglyTypedId" Version="1.0.0-beta08" PrivateAssets="all" ExcludeAssets="runtime" />
<PackageReference Include="StronglyTypedId.Templates" Version="1.0.0-beta08" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Vegasco.Server.AppHost.Shared\Vegasco.Server.AppHost.Shared.csproj" />
<ProjectReference Include="..\Vegasco.Server.ServiceDefaults\Vegasco.Server.ServiceDefaults.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Update="Nerdbank.GitVersioning" Version="3.7.115" />
</ItemGroup>
</Project>

View File

@@ -1,10 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Warning",
"Vegasco": "Information",
"Microsoft.Hosting.Lifetime": "Information"
}
},
"AllowedHosts": "*"
}

View File

@@ -1,16 +0,0 @@
namespace Vegasco.Server.AppHost.Shared;
public static class Constants
{
public static class Projects
{
public const string Api = "Vegasco-Server-Api";
}
public static class Database
{
public const string ServiceName = "postgres";
public const string Name = "vegasco-database";
}
}

View File

@@ -1,15 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Update="Nerdbank.GitVersioning">
<Version>3.7.115</Version>
</PackageReference>
</ItemGroup>
</Project>

View File

@@ -1,22 +0,0 @@
using Vegasco.Server.AppHost.Shared;
IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(args);
IResourceBuilder<PostgresDatabaseResource> postgres = builder.AddPostgres(Constants.Database.ServiceName)
.WithLifetime(ContainerLifetime.Persistent)
.WithDataVolume()
.AddDatabase(Constants.Database.Name);
IResourceBuilder<ProjectResource> api = builder
.AddProject<Projects.Vegasco_Server_Api>(Constants.Projects.Api)
.WithReference(postgres)
.WaitFor(postgres);
builder
.AddNpmApp("Vegasco-Web", "../Vegasco-Web")
.WithReference(api)
.WaitFor(api)
.WithHttpEndpoint(port: 44200, env: "PORT", isProxied: false)
.WithExternalHttpEndpoints();
builder.Build().Run();

View File

@@ -1,29 +0,0 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:17055;http://localhost:15102",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"DOTNET_ENVIRONMENT": "Development",
"DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21122",
"DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22235"
}
},
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:15102",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"DOTNET_ENVIRONMENT": "Development",
"DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19222",
"DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20257"
}
}
}
}

View File

@@ -1,28 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<Sdk Name="Aspire.AppHost.Sdk" Version="9.0.0" />
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsAspireHost>true</IsAspireHost>
<UserSecretsId>bb714834-9872-4af6-b154-0b98b14fcca2</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Aspire.Hosting.AppHost" Version="9.3.0" />
<PackageReference Include="Aspire.Hosting.NodeJs" Version="9.3.1" />
<PackageReference Include="Aspire.Hosting.PostgreSQL" Version="9.3.0" />
<PackageReference Update="Nerdbank.GitVersioning">
<Version>3.7.115</Version>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Vegasco.Server.AppHost.Shared\Vegasco.Server.AppHost.Shared.csproj" IsAspireProjectResource="false" />
<ProjectReference Include="..\Vegasco.Server.Api\Vegasco.Server.Api.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,9 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Aspire.Hosting.Dcp": "Warning"
}
}
}

View File

@@ -1,119 +0,0 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using OpenTelemetry;
using OpenTelemetry.Metrics;
using OpenTelemetry.Trace;
namespace Vegasco.Server.ServiceDefaults;
// Adds common .NET Aspire services: service discovery, resilience, health checks, and OpenTelemetry.
// This project should be referenced by each service project in your solution.
// To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults
public static class Extensions
{
public static TBuilder AddServiceDefaults<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
{
builder.ConfigureOpenTelemetry();
builder.AddDefaultHealthChecks();
builder.Services.AddServiceDiscovery();
builder.Services.ConfigureHttpClientDefaults(http =>
{
// Turn on resilience by default
http.AddStandardResilienceHandler();
// Turn on service discovery by default
http.AddServiceDiscovery();
});
// Uncomment the following to restrict the allowed schemes for service discovery.
// builder.Services.Configure<ServiceDiscoveryOptions>(options =>
// {
// options.AllowedSchemes = ["https"];
// });
return builder;
}
public static TBuilder ConfigureOpenTelemetry<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
{
builder.Logging.AddOpenTelemetry(logging =>
{
logging.IncludeFormattedMessage = true;
logging.IncludeScopes = true;
});
builder.Services.AddOpenTelemetry()
.WithMetrics(metrics =>
{
metrics.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddRuntimeInstrumentation();
})
.WithTracing(tracing =>
{
tracing.AddSource(builder.Environment.ApplicationName)
.AddAspNetCoreInstrumentation()
// Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package)
//.AddGrpcClientInstrumentation()
.AddHttpClientInstrumentation();
});
builder.AddOpenTelemetryExporters();
return builder;
}
private static TBuilder AddOpenTelemetryExporters<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
{
bool useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]);
if (useOtlpExporter)
{
builder.Services.AddOpenTelemetry().UseOtlpExporter();
}
// Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package)
//if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"]))
//{
// builder.Services.AddOpenTelemetry()
// .UseAzureMonitor();
//}
return builder;
}
public static TBuilder AddDefaultHealthChecks<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
{
builder.Services.AddHealthChecks()
// Add a default liveness check to ensure app is responsive
.AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]);
return builder;
}
public static WebApplication MapDefaultEndpoints(this WebApplication app)
{
// Adding health checks endpoints to applications in non-development environments has security implications.
// See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments.
if (app.Environment.IsDevelopment())
{
// All health checks must pass for app to be considered ready to accept traffic after starting
app.MapHealthChecks("/health");
// Only health checks tagged with the "live" tag must pass for app to be considered alive
app.MapHealthChecks("/alive", new HealthCheckOptions
{
Predicate = r => r.Tags.Contains("live")
});
}
return app;
}
}

View File

@@ -1,25 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsAspireSharedProject>true</IsAspireSharedProject>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="9.5.0" />
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery" Version="9.3.0" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.12.0" />
<PackageReference Update="Nerdbank.GitVersioning">
<Version>3.7.115</Version>
</PackageReference>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,30 @@
# See https://aka.ms/customizecontainer to learn how to customize your debug container and how Visual Studio uses this Dockerfile to build your images for faster debugging.
# This stage is used when running from VS in fast mode (Default for Debug configuration)
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
USER app
WORKDIR /app
EXPOSE 8080
EXPOSE 8081
# This stage is used to build the service project
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["Vegasco.Api/Vegasco.Api.csproj", "Vegasco.Api/"]
RUN dotnet restore "./Vegasco.Api/Vegasco.Api.csproj"
COPY . .
WORKDIR "/src/Vegasco.Api"
RUN dotnet build "./Vegasco.Api.csproj" -c $BUILD_CONFIGURATION -o /app/build
# This stage is used to publish the service project to be copied to the final stage
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "./Vegasco.Api.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
# This stage is used in production or when running from VS in regular mode (Default when not using the Debug configuration)
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "Vegasco.Api.dll"]

View File

@@ -0,0 +1,44 @@
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
var summaries = new[]
{
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
};
app.MapGet("/weatherforecast", () =>
{
var forecast = Enumerable.Range(1, 5).Select(index =>
new WeatherForecast
(
DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
Random.Shared.Next(-20, 55),
summaries[Random.Shared.Next(summaries.Length)]
))
.ToArray();
return forecast;
})
.WithName("GetWeatherForecast")
.WithOpenApi();
app.Run();
internal record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary)
{
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
}

View File

@@ -0,0 +1,52 @@
{
"profiles": {
"http": {
"commandName": "Project",
"launchBrowser": true,
"launchUrl": "swagger",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"dotnetRunMessages": true,
"applicationUrl": "http://localhost:5236"
},
"https": {
"commandName": "Project",
"launchBrowser": true,
"launchUrl": "swagger",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"dotnetRunMessages": true,
"applicationUrl": "https://localhost:7226;http://localhost:5236"
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"launchUrl": "swagger",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"Container (Dockerfile)": {
"commandName": "Docker",
"launchBrowser": true,
"launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}/swagger",
"environmentVariables": {
"ASPNETCORE_HTTPS_PORTS": "8081",
"ASPNETCORE_HTTP_PORTS": "8080"
},
"publishAllPorts": true,
"useSSL": true
}
},
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:40988",
"sslPort": 44347
}
}
}

View File

@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>2855ad97-67f4-455a-81af-69c212566ff2</UserSecretsId>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.0" />
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.21.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.1.0" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,6 @@
@Vegasco.Api_HostAddress = http://localhost:5236
GET {{Vegasco.Api_HostAddress}}/weatherforecast/
Accept: application/json
###

View File

@@ -4,5 +4,6 @@
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
},
"AllowedHosts": "*"
}

View File

@@ -1,19 +0,0 @@
using Bogus;
using Vegasco.Server.Api.Cars;
namespace Vegasco.Server.Api.Tests.Integration;
internal class CarFaker
{
private readonly Faker _faker = new();
internal CreateCar.Request CreateCarRequest()
{
return new CreateCar.Request(_faker.Vehicle.Model());
}
internal UpdateCar.Request UpdateCarRequest()
{
return new UpdateCar.Request(_faker.Vehicle.Model());
}
}

View File

@@ -1,71 +0,0 @@
using FluentAssertions;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using System.Net;
using System.Net.Http.Json;
using Vegasco.Server.Api.Cars;
using Vegasco.Server.Api.Persistence;
namespace Vegasco.Server.Api.Tests.Integration.Cars;
[Collection(SharedTestCollection.Name)]
public class CreateCarTests : IAsyncLifetime
{
private readonly WebAppFactory _factory;
private readonly IServiceScope _scope;
private readonly ApplicationDbContext _dbContext;
private readonly CarFaker _carFaker = new();
public CreateCarTests(WebAppFactory factory)
{
_factory = factory;
_scope = _factory.Services.CreateScope();
_dbContext = _scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
}
[Fact]
public async Task CreateCar_ShouldCreateCar_WhenRequestIsValid()
{
// Arrange
CreateCar.Request createCarRequest = _carFaker.CreateCarRequest();
// Act
HttpResponseMessage response = await _factory.HttpClient.PostAsJsonAsync("v1/cars", createCarRequest);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Created);
var createdCar = await response.Content.ReadFromJsonAsync<CreateCar.Response>();
createdCar.Should().BeEquivalentTo(createCarRequest, o => o.ExcludingMissingMembers());
_dbContext.Cars.Should().ContainEquivalentOf(createdCar, o => o.Excluding(x => x!.Id))
.Which.Id.Value.Should().Be(createdCar!.Id);
}
[Fact]
public async Task CreateCar_ShouldReturnValidationProblems_WhenRequestIsNotValid()
{
// Arrange
var createCarRequest = new CreateCar.Request("");
// Act
HttpResponseMessage response = await _factory.HttpClient.PostAsJsonAsync("v1/cars", createCarRequest);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var validationProblemDetails = await response.Content.ReadFromJsonAsync<ValidationProblemDetails>();
validationProblemDetails!.Errors.Keys.Should().Contain(x =>
x.Equals(nameof(CreateCar.Request.Name), StringComparison.OrdinalIgnoreCase));
_dbContext.Cars.Should().NotContainEquivalentOf(createCarRequest, o => o.ExcludingMissingMembers());
}
public Task InitializeAsync() => Task.CompletedTask;
public async Task DisposeAsync()
{
_scope.Dispose();
await _dbContext.DisposeAsync();
await _factory.ResetDatabaseAsync();
}
}

View File

@@ -1,64 +0,0 @@
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using System.Net;
using System.Net.Http.Json;
using Vegasco.Server.Api.Cars;
using Vegasco.Server.Api.Persistence;
namespace Vegasco.Server.Api.Tests.Integration.Cars;
[Collection(SharedTestCollection.Name)]
public class DeleteCarTests : IAsyncLifetime
{
private readonly WebAppFactory _factory;
private readonly IServiceScope _scope;
private readonly ApplicationDbContext _dbContext;
private readonly CarFaker _carFaker = new();
public DeleteCarTests(WebAppFactory factory)
{
_factory = factory;
_scope = _factory.Services.CreateScope();
_dbContext = _scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
}
[Fact]
public async Task DeleteCar_ShouldReturnNotFound_WhenCarDoesNotExist()
{
// Arrange
var randomCarId = Guid.NewGuid();
// Act
HttpResponseMessage response = await _factory.HttpClient.DeleteAsync($"v1/cars/{randomCarId}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
[Fact]
public async Task DeleteCar_ShouldDeleteCar_WhenCarExists()
{
// Arrange
CreateCar.Request createCarRequest = _carFaker.CreateCarRequest();
HttpResponseMessage createCarResponse = await _factory.HttpClient.PostAsJsonAsync("v1/cars", createCarRequest);
createCarResponse.EnsureSuccessStatusCode();
var createdCar = await createCarResponse.Content.ReadFromJsonAsync<CreateCar.Response>();
// Act
HttpResponseMessage response = await _factory.HttpClient.DeleteAsync($"v1/cars/{createdCar!.Id}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NoContent);
_dbContext.Cars.Should().BeEmpty();
}
public Task InitializeAsync() => Task.CompletedTask;
public async Task DisposeAsync()
{
_scope.Dispose();
await _dbContext.DisposeAsync();
await _factory.ResetDatabaseAsync();
}
}

View File

@@ -1,57 +0,0 @@
using FluentAssertions;
using System.Net;
using System.Net.Http.Json;
using Vegasco.Server.Api.Cars;
namespace Vegasco.Server.Api.Tests.Integration.Cars;
[Collection(SharedTestCollection.Name)]
public class GetCarTests : IAsyncLifetime
{
private readonly WebAppFactory _factory;
private readonly CarFaker _carFaker = new();
public GetCarTests(WebAppFactory factory)
{
_factory = factory;
}
[Fact]
public async Task GetCar_ShouldReturnNotFound_WhenCarDoesNotExist()
{
// Arrange
var randomCarId = Guid.NewGuid();
// Act
HttpResponseMessage response = await _factory.HttpClient.GetAsync($"v1/cars/{randomCarId}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
[Fact]
public async Task GetCar_ShouldReturnCar_WhenCarExists()
{
// Arrange
CreateCar.Request createCarRequest = _carFaker.CreateCarRequest();
HttpResponseMessage createCarResponse = await _factory.HttpClient.PostAsJsonAsync("v1/cars", createCarRequest);
createCarResponse.EnsureSuccessStatusCode();
var createdCar = await createCarResponse.Content.ReadFromJsonAsync<CreateCar.Response>();
// Act
HttpResponseMessage response = await _factory.HttpClient.GetAsync($"v1/cars/{createdCar!.Id}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var car = await response.Content.ReadFromJsonAsync<GetCar.Response>();
car.Should().BeEquivalentTo(createdCar);
}
public Task InitializeAsync() => Task.CompletedTask;
public async Task DisposeAsync()
{
await _factory.ResetDatabaseAsync();
}
}

View File

@@ -1,66 +0,0 @@
using FluentAssertions;
using System.Net;
using System.Net.Http.Json;
using Vegasco.Server.Api.Cars;
namespace Vegasco.Server.Api.Tests.Integration.Cars;
[Collection(SharedTestCollection.Name)]
public class GetCarsTests : IAsyncLifetime
{
private readonly WebAppFactory _factory;
private readonly CarFaker _carFaker = new();
public GetCarsTests(WebAppFactory factory)
{
_factory = factory;
}
[Fact]
public async Task GetCars_ShouldReturnEmptyList_WhenNoEntriesExist()
{
// Arrange
// Act
using HttpResponseMessage response = await _factory.HttpClient.GetAsync("v1/cars");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var apiResponse = await response.Content.ReadFromJsonAsync<GetCars.ApiResponse>();
apiResponse!.Cars.Should().BeEmpty();
}
[Fact]
public async Task GetCars_ShouldReturnEntries_WhenEntriesExist()
{
// Arrange
List<CreateCar.Response> createdCars = [];
const int numberOfCars = 5;
for (var i = 0; i < numberOfCars; i++)
{
CreateCar.Request createCarRequest = _carFaker.CreateCarRequest();
HttpResponseMessage createCarResponse = await _factory.HttpClient.PostAsJsonAsync("v1/cars", createCarRequest);
createCarResponse.EnsureSuccessStatusCode();
var createdCar = await createCarResponse.Content.ReadFromJsonAsync<CreateCar.Response>();
createdCars.Add(createdCar!);
}
// Act
using HttpResponseMessage response = await _factory.HttpClient.GetAsync("v1/cars");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var apiResponse = await response.Content.ReadFromJsonAsync<GetCars.ApiResponse>();
apiResponse!.Cars.Should().BeEquivalentTo(createdCars);
}
public Task InitializeAsync() => Task.CompletedTask;
public async Task DisposeAsync()
{
await _factory.ResetDatabaseAsync();
}
}

Some files were not shown because too many files have changed in this diff Show More