From a708ed25e7cf5491bc0193b551e0161d41efc4bd Mon Sep 17 00:00:00 2001 From: ThompsonNye Date: Sat, 17 Aug 2024 16:38:40 +0200 Subject: [PATCH] Initial endpoint configuration with authentication --- README.md | 59 ++++++++- src/Api/Api.csproj | 19 --- src/Api/Program.cs | 6 - src/Api/StartupExtensions.cs | 29 ---- src/WebApi/Authentication/JwtOptions.cs | 31 +++++ src/WebApi/Cars/Car.cs | 10 ++ src/WebApi/Cars/CreateCar.cs | 38 ++++++ src/WebApi/Common/Constants.cs | 11 ++ .../Common/DependencyInjectionExtensions.cs | 124 ++++++++++++++++++ src/WebApi/Common/IWebApiMarker.cs | 3 + src/WebApi/Common/StartupExtensions.cs | 62 +++++++++ src/WebApi/Common/ValidatorExtensions.cs | 94 +++++++++++++ src/{Api => WebApi}/Dockerfile | 0 src/WebApi/Endpoints/EndpointExtensions.cs | 39 ++++++ src/WebApi/Endpoints/IEndpoint.cs | 6 + .../OpenApi/ConfigureSwaggerGenOptions.cs | 56 ++++++++ .../Endpoints/OpenApi/SwaggerDocConstants.cs | 6 + src/WebApi/Program.cs | 6 + .../Properties/launchSettings.json | 0 src/WebApi/WebApi.csproj | 28 ++++ .../appsettings.Development.json | 0 src/{Api => WebApi}/appsettings.json | 0 vegasco-server.sln | 17 ++- 23 files changed, 587 insertions(+), 57 deletions(-) delete mode 100644 src/Api/Api.csproj delete mode 100644 src/Api/Program.cs delete mode 100644 src/Api/StartupExtensions.cs create mode 100644 src/WebApi/Authentication/JwtOptions.cs create mode 100644 src/WebApi/Cars/Car.cs create mode 100644 src/WebApi/Cars/CreateCar.cs create mode 100644 src/WebApi/Common/Constants.cs create mode 100644 src/WebApi/Common/DependencyInjectionExtensions.cs create mode 100644 src/WebApi/Common/IWebApiMarker.cs create mode 100644 src/WebApi/Common/StartupExtensions.cs create mode 100644 src/WebApi/Common/ValidatorExtensions.cs rename src/{Api => WebApi}/Dockerfile (100%) create mode 100644 src/WebApi/Endpoints/EndpointExtensions.cs create mode 100644 src/WebApi/Endpoints/IEndpoint.cs create mode 100644 src/WebApi/Endpoints/OpenApi/ConfigureSwaggerGenOptions.cs create mode 100644 src/WebApi/Endpoints/OpenApi/SwaggerDocConstants.cs create mode 100644 src/WebApi/Program.cs rename src/{Api => WebApi}/Properties/launchSettings.json (100%) create mode 100644 src/WebApi/WebApi.csproj rename src/{Api => WebApi}/appsettings.Development.json (100%) rename src/{Api => WebApi}/appsettings.json (100%) diff --git a/README.md b/README.md index 0845b31..8adf979 100644 --- a/README.md +++ b/README.md @@ -1 +1,58 @@ -# vegasco-server \ No newline at end of file +# Vegasco Server + +Backend for the vegasco (***VE***hicle ***GAS*** ***CO***nsumption) application. + +## Getting Started + +### Configuration + +| Configuration | Description | Default | Required | +| --- | --- | --- | --- | +| JWT:Authority | The authority of the JWT token. | - | true | +| JWT:Audience | The audience of the JWT token. | - | true | +| JWT:Issuer | The issuer of the JWT token. | - | true | +| JWT:NameClaimType | The type of the name claim. | `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name` (C# constant `ClaimTypes.Name` | 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" + } +} +``` diff --git a/src/Api/Api.csproj b/src/Api/Api.csproj deleted file mode 100644 index 346136f..0000000 --- a/src/Api/Api.csproj +++ /dev/null @@ -1,19 +0,0 @@ - - - - net8.0 - enable - enable - 4bf893d3-0c16-41ec-8b46-2768d841215d - Linux - ..\.. - Vegasco.Api - - - - - - - - - diff --git a/src/Api/Program.cs b/src/Api/Program.cs deleted file mode 100644 index b072d92..0000000 --- a/src/Api/Program.cs +++ /dev/null @@ -1,6 +0,0 @@ -using Vegasco.Api; - -WebApplication.CreateBuilder(args) - .ConfigureServices() - .ConfigureRequestPipeline() - .Run(); diff --git a/src/Api/StartupExtensions.cs b/src/Api/StartupExtensions.cs deleted file mode 100644 index a785df1..0000000 --- a/src/Api/StartupExtensions.cs +++ /dev/null @@ -1,29 +0,0 @@ -namespace Vegasco.Api; - -internal static class StartupExtensions -{ - internal static WebApplication ConfigureServices(this WebApplicationBuilder builder) - { - builder.Services.AddEndpointsApiExplorer(); - builder.Services.AddSwaggerGen(); - - builder.Services.AddHealthChecks(); - - return builder.Build(); - } - - internal static WebApplication ConfigureRequestPipeline(this WebApplication app) - { - if (app.Environment.IsDevelopment()) - { - app.UseSwagger(); - app.UseSwaggerUI(); - } - - app.UseHttpsRedirection(); - - app.MapHealthChecks("/health"); - - return app; - } -} \ No newline at end of file diff --git a/src/WebApi/Authentication/JwtOptions.cs b/src/WebApi/Authentication/JwtOptions.cs new file mode 100644 index 0000000..e05daa0 --- /dev/null +++ b/src/WebApi/Authentication/JwtOptions.cs @@ -0,0 +1,31 @@ +using FluentValidation; + +namespace Vegasco.WebApi.Authentication; + +public class JwtOptions +{ + public const string SectionName = "JWT"; + + public string Audience { get; set; } = ""; + + public string Authority { get; set; } = ""; + + public string Issuer { get; set; } = ""; + + public string? NameClaimType { get; set; } +} + +public class JwtOptionsValidator : AbstractValidator +{ + public JwtOptionsValidator() + { + RuleFor(x => x.Audience) + .NotEmpty(); + + RuleFor(x => x.Authority) + .NotEmpty(); + + RuleFor(x => x.Issuer) + .NotEmpty(); + } +} \ No newline at end of file diff --git a/src/WebApi/Cars/Car.cs b/src/WebApi/Cars/Car.cs new file mode 100644 index 0000000..da1e72b --- /dev/null +++ b/src/WebApi/Cars/Car.cs @@ -0,0 +1,10 @@ +namespace Vegasco.WebApi.Cars; + +public class Car +{ + public Guid Id { get; set; } = Guid.NewGuid(); + + public string Name { get; set; } = ""; + + public Guid UserId { get; set; } +} diff --git a/src/WebApi/Cars/CreateCar.cs b/src/WebApi/Cars/CreateCar.cs new file mode 100644 index 0000000..ed7630e --- /dev/null +++ b/src/WebApi/Cars/CreateCar.cs @@ -0,0 +1,38 @@ +using FluentValidation; +using FluentValidation.Results; +using Vegasco.WebApi.Common; + +namespace Vegasco.WebApi.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", Handler) + .WithTags("Cars"); + } + + public class Validator : AbstractValidator + { + public Validator() + { + RuleFor(x => x.Name) + .NotEmpty(); + } + } + + public static async Task Handler(Request request, IEnumerable> validators) + { + List failedValidations = await validators.ValidateAllAsync(request); + if (failedValidations.Count > 0) + { + return Results.BadRequest(new HttpValidationProblemDetails(failedValidations.ToCombinedDictionary())); + } + + return Results.Ok(); + } +} diff --git a/src/WebApi/Common/Constants.cs b/src/WebApi/Common/Constants.cs new file mode 100644 index 0000000..bc15221 --- /dev/null +++ b/src/WebApi/Common/Constants.cs @@ -0,0 +1,11 @@ +namespace Vegasco.WebApi.Common; + +public static class Constants +{ + public const string AppOtelName = "Vegasco.Api"; + + public static class Authorization + { + public const string RequireAuthenticatedUserPolicy = "RequireAuthenticatedUser"; + } +} \ No newline at end of file diff --git a/src/WebApi/Common/DependencyInjectionExtensions.cs b/src/WebApi/Common/DependencyInjectionExtensions.cs new file mode 100644 index 0000000..19de60f --- /dev/null +++ b/src/WebApi/Common/DependencyInjectionExtensions.cs @@ -0,0 +1,124 @@ +using Asp.Versioning; +using FluentValidation; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.Extensions.Options; +using OpenTelemetry.Trace; +using System.Diagnostics; +using Vegasco.WebApi.Authentication; +using Vegasco.WebApi.Endpoints; +using Vegasco.WebApi.Endpoints.OpenApi; + +namespace Vegasco.WebApi.Common; + +public static class DependencyInjectionExtensions +{ + /// + /// Adds all the WebApi related services to the Dependency Injection container. + /// + /// + public static void AddWebApiServices(this IServiceCollection services) + { + services + .AddMiscellaneousServices() + .AddOpenApi() + .AddApiVersioning() + .AddOtel() + .AddAuthenticationAndAuthorization(); + } + + private static IServiceCollection AddMiscellaneousServices(this IServiceCollection services) + { + services.AddResponseCompression(); + + services.AddValidatorsFromAssemblies( + [ + typeof(IWebApiMarker).Assembly + ], ServiceLifetime.Singleton); + + services.AddHealthChecks(); + services.AddEndpointsFromAssemblyContaining(); + + return services; + } + + private static IServiceCollection AddOpenApi(this IServiceCollection services) + { + services.ConfigureOptions(); + + services.AddEndpointsApiExplorer(); + services.AddSwaggerGen(); + + 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 AddOtel(this IServiceCollection services) + { + Activity.DefaultIdFormat = ActivityIdFormat.W3C; + + ActivitySource activitySource = new(Constants.AppOtelName); + services.AddSingleton(activitySource); + + services.AddOpenTelemetry() + .WithTracing(t => + { + t.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddOtlpExporter() + .AddSource(activitySource.Name); + }) + .WithMetrics(); + + return services; + } + + private static IServiceCollection AddAuthenticationAndAuthorization(this IServiceCollection services) + { + services.AddOptions() + .BindConfiguration(JwtOptions.SectionName) + .ValidateFluently() + .ValidateOnStart(); + + var jwtOptions = services.BuildServiceProvider().GetRequiredService>(); + + services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, o => + { + o.Authority = jwtOptions.Value.Authority; + + o.TokenValidationParameters.ValidAudience = jwtOptions.Value.Audience; + o.TokenValidationParameters.ValidateAudience = true; + + o.TokenValidationParameters.ValidIssuer = jwtOptions.Value.Issuer; + o.TokenValidationParameters.ValidateIssuer = true; + + if (!string.IsNullOrWhiteSpace(jwtOptions.Value.NameClaimType)) + { + o.TokenValidationParameters.NameClaimType = jwtOptions.Value.NameClaimType; + } + }); + + services.AddAuthorizationBuilder() + .AddPolicy(Constants.Authorization.RequireAuthenticatedUserPolicy, p => p + .RequireAuthenticatedUser() + .AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme)); + + return services; + } +} diff --git a/src/WebApi/Common/IWebApiMarker.cs b/src/WebApi/Common/IWebApiMarker.cs new file mode 100644 index 0000000..bcd335d --- /dev/null +++ b/src/WebApi/Common/IWebApiMarker.cs @@ -0,0 +1,3 @@ +namespace Vegasco.WebApi.Common; + +public interface IWebApiMarker; diff --git a/src/WebApi/Common/StartupExtensions.cs b/src/WebApi/Common/StartupExtensions.cs new file mode 100644 index 0000000..2e1b332 --- /dev/null +++ b/src/WebApi/Common/StartupExtensions.cs @@ -0,0 +1,62 @@ +using Asp.Versioning.ApiExplorer; +using Microsoft.AspNetCore.Localization; +using System.Globalization; +using Vegasco.WebApi.Endpoints; + +namespace Vegasco.WebApi.Common; + +internal static class StartupExtensions +{ + internal static WebApplication ConfigureServices(this WebApplicationBuilder builder) + { + builder.Configuration.AddEnvironmentVariables("Vegasco_"); + + builder.Services.AddWebApiServices(); + + WebApplication app = builder.Build(); + return app; + } + + internal static WebApplication ConfigureRequestPipeline(this WebApplication app) + { + app.UseRequestLocalization(o => + { + o.SupportedCultures = + [ + new CultureInfo("en") + ]; + + o.SupportedUICultures = o.SupportedCultures; + + CultureInfo defaultCulture = o.SupportedCultures[0]; + o.DefaultRequestCulture = new RequestCulture(defaultCulture); + }); + + app.UseHttpsRedirection(); + + app.MapHealthChecks("/health"); + + app.UseAuthentication(); + app.UseAuthorization(); + + app.MapEndpoints(); + + if (app.Environment.IsDevelopment()) + { + app.UseSwagger(); + app.UseSwaggerUI(o => + { + // Create a Swagger endpoint for each API version + IReadOnlyList 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; + } +} diff --git a/src/WebApi/Common/ValidatorExtensions.cs b/src/WebApi/Common/ValidatorExtensions.cs new file mode 100644 index 0000000..2ee107f --- /dev/null +++ b/src/WebApi/Common/ValidatorExtensions.cs @@ -0,0 +1,94 @@ +using FluentValidation; +using FluentValidation.Results; +using Microsoft.Extensions.Options; + +namespace Vegasco.WebApi.Common; + +public static class ValidatorExtensions +{ + /// + /// Asynchronously validates an instance of against all instances in . + /// + /// + /// + /// + /// The failed validation results. + public static async Task> ValidateAllAsync(this IEnumerable> validators, T instance) + { + var validationTasks = validators + .Select(validator => validator.ValidateAsync(instance)) + .ToList(); + + await Task.WhenAll(validationTasks); + + List failedValidations = validationTasks + .Select(x => x.Result) + .Where(x => !x.IsValid) + .ToList(); + + return failedValidations; + } + + public static Dictionary ToCombinedDictionary(this IEnumerable validationResults) + { + // Use a hash set to avoid duplicate error messages. + Dictionary> combinedErrors = []; + + foreach (var error in validationResults.SelectMany(x => x.Errors)) + { + if (!combinedErrors.TryGetValue(error.PropertyName, out HashSet? 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 ValidateFluently(this OptionsBuilder builder) + where T : class + { + builder.Services.AddTransient>(serviceProvider => + { + var validators = serviceProvider.GetServices>() ?? []; + return new FluentValidationOptions(builder.Name, validators); + }); + return builder; + } +} + +internal class FluentValidationOptions : IValidateOptions + where TOptions : class +{ + private readonly IEnumerable> _validators; + + public string? Name { get; set; } + + public FluentValidationOptions(string? name, IEnumerable> 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); + + var failedValidations = _validators.ValidateAllAsync(options).Result; + if (failedValidations.Count == 0) + { + return ValidateOptionsResult.Success; + } + + return ValidateOptionsResult.Fail(failedValidations.SelectMany(x => x.Errors.Select(x => x.ErrorMessage))); + } +} \ No newline at end of file diff --git a/src/Api/Dockerfile b/src/WebApi/Dockerfile similarity index 100% rename from src/Api/Dockerfile rename to src/WebApi/Dockerfile diff --git a/src/WebApi/Endpoints/EndpointExtensions.cs b/src/WebApi/Endpoints/EndpointExtensions.cs new file mode 100644 index 0000000..17cc430 --- /dev/null +++ b/src/WebApi/Endpoints/EndpointExtensions.cs @@ -0,0 +1,39 @@ +using Asp.Versioning.Builder; +using Asp.Versioning.Conventions; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Vegasco.WebApi.Cars; +using Vegasco.WebApi.Common; + +namespace Vegasco.WebApi.Endpoints; + +public static class EndpointExtensions +{ + public static IServiceCollection AddEndpointsFromAssemblyContaining(this IServiceCollection services) + { + var assembly = typeof(T).Assembly; + + ServiceDescriptor[] serviceDescriptors = assembly + .DefinedTypes + .Where(type => type is { IsAbstract: false, IsInterface: false } && + type.IsAssignableTo(typeof(IEndpoint))) + .Select(type => ServiceDescriptor.Transient(typeof(IEndpoint), type)) + .ToArray(); + + services.TryAddEnumerable(serviceDescriptors); + + return services; + } + + public static void MapEndpoints(this IEndpointRouteBuilder builder) + { + ApiVersionSet apiVersionSet = builder.NewApiVersionSet() + .HasApiVersion(1.0) + .Build(); + + RouteGroupBuilder versionedApis = builder.MapGroup("/v{apiVersion:apiVersion}") + .WithApiVersionSet(apiVersionSet) + .RequireAuthorization(Constants.Authorization.RequireAuthenticatedUserPolicy); + + CreateCar.MapEndpoint(versionedApis); + } +} diff --git a/src/WebApi/Endpoints/IEndpoint.cs b/src/WebApi/Endpoints/IEndpoint.cs new file mode 100644 index 0000000..15e0c50 --- /dev/null +++ b/src/WebApi/Endpoints/IEndpoint.cs @@ -0,0 +1,6 @@ +namespace Vegasco.WebApi.Endpoints; + +public interface IEndpoint +{ + void MapEndpoint(IEndpointRouteBuilder builder); +} diff --git a/src/WebApi/Endpoints/OpenApi/ConfigureSwaggerGenOptions.cs b/src/WebApi/Endpoints/OpenApi/ConfigureSwaggerGenOptions.cs new file mode 100644 index 0000000..571dde8 --- /dev/null +++ b/src/WebApi/Endpoints/OpenApi/ConfigureSwaggerGenOptions.cs @@ -0,0 +1,56 @@ +using Asp.Versioning.ApiExplorer; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Options; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace Vegasco.WebApi.Endpoints.OpenApi; + +/// +/// Registers each api version as its own swagger document. +/// +/// +public class ConfigureSwaggerGenOptions( + IApiVersionDescriptionProvider versionDescriptionProvider) + : IConfigureNamedOptions +{ + private readonly IApiVersionDescriptionProvider _versionDescriptionProvider = versionDescriptionProvider; + + public void Configure(SwaggerGenOptions options) + { + foreach (ApiVersionDescription description in _versionDescriptionProvider.ApiVersionDescriptions) + { + OpenApiSecurityScheme securityScheme = new() + { + Name = "Bearer", + In = ParameterLocation.Header, + Type = SecuritySchemeType.Http, + Scheme = "bearer", + Reference = new OpenApiReference + { + Id = IdentityConstants.BearerScheme, + Type = ReferenceType.SecurityScheme + } + }; + options.AddSecurityDefinition(securityScheme.Reference.Id, securityScheme); + + options.AddSecurityRequirement(new OpenApiSecurityRequirement + { + { securityScheme, Array.Empty() } + }); + + OpenApiInfo openApiInfo = new() + { + Title = "Vegasco API", + Version = description.ApiVersion.ToString() + }; + + options.SwaggerDoc(description.GroupName, openApiInfo); + } + } + + public void Configure(string? name, SwaggerGenOptions options) + { + Configure(options); + } +} diff --git a/src/WebApi/Endpoints/OpenApi/SwaggerDocConstants.cs b/src/WebApi/Endpoints/OpenApi/SwaggerDocConstants.cs new file mode 100644 index 0000000..e88de76 --- /dev/null +++ b/src/WebApi/Endpoints/OpenApi/SwaggerDocConstants.cs @@ -0,0 +1,6 @@ +namespace Vegasco.WebApi.Endpoints.OpenApi; + +public static class SwaggerDocConstants +{ + +} diff --git a/src/WebApi/Program.cs b/src/WebApi/Program.cs new file mode 100644 index 0000000..fd643eb --- /dev/null +++ b/src/WebApi/Program.cs @@ -0,0 +1,6 @@ +using Vegasco.WebApi.Common; + +WebApplication.CreateBuilder(args) + .ConfigureServices() + .ConfigureRequestPipeline() + .Run(); diff --git a/src/Api/Properties/launchSettings.json b/src/WebApi/Properties/launchSettings.json similarity index 100% rename from src/Api/Properties/launchSettings.json rename to src/WebApi/Properties/launchSettings.json diff --git a/src/WebApi/WebApi.csproj b/src/WebApi/WebApi.csproj new file mode 100644 index 0000000..aaa80ac --- /dev/null +++ b/src/WebApi/WebApi.csproj @@ -0,0 +1,28 @@ + + + + net8.0 + enable + enable + 4bf893d3-0c16-41ec-8b46-2768d841215d + Linux + ..\.. + Vegasco.WebApi + + + + + + + + + + + + + + + + + + diff --git a/src/Api/appsettings.Development.json b/src/WebApi/appsettings.Development.json similarity index 100% rename from src/Api/appsettings.Development.json rename to src/WebApi/appsettings.Development.json diff --git a/src/Api/appsettings.json b/src/WebApi/appsettings.json similarity index 100% rename from src/Api/appsettings.json rename to src/WebApi/appsettings.json diff --git a/vegasco-server.sln b/vegasco-server.sln index 366336c..da38768 100644 --- a/vegasco-server.sln +++ b/vegasco-server.sln @@ -1,9 +1,16 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 +# 17 VisualStudioVersion = 17.0.31903.59 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Api", "src\Api\Api.csproj", "{9FF3C98A-5085-4EBE-A980-DB2148B0C00A}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WebApi", "src\WebApi\WebApi.csproj", "{9FF3C98A-5085-4EBE-A980-DB2148B0C00A}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{C051A684-BD6A-43F2-B0CC-F3C2315D99E3}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{A16251C2-47DB-4017-812B-CA18B280E049}" + ProjectSection(SolutionItems) = preProject + README.md = README.md + EndProjectSection EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -19,4 +26,10 @@ Global GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {9FF3C98A-5085-4EBE-A980-DB2148B0C00A} = {C051A684-BD6A-43F2-B0CC-F3C2315D99E3} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {7813E32D-AE19-479C-853B-063882D2D05A} + EndGlobalSection EndGlobal