Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion API/Controller/Tokens/Tokens.cs
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,6 @@ public async Task<IActionResult> EditTokenV2([FromRoute] Guid tokenId, [FromBody
/// <response code="404">The token does not exist or you do not have access to it.</response>
[HttpPatch("{tokenId}/paused")]
[Consumes(MediaTypeNames.Application.Json)]
[Produces(MediaTypeNames.Application.Json)]
[ProducesResponseType<TokenPausedResponse>(StatusCodes.Status200OK, MediaTypeNames.Application.Json)]
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status404NotFound, MediaTypeNames.Application.ProblemJson)] // ApiTokenNotFound
[MapToApiVersion("2")]
Expand Down
3 changes: 0 additions & 3 deletions API/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
using OpenShock.Common.Services;
using OpenShock.Common.Services.Device;
using OpenShock.Common.Services.Ota;
using OpenShock.Common.Swagger;
using Serilog;
using OAuthConstants = OpenShock.API.OAuth.OAuthConstants;

Expand Down Expand Up @@ -129,8 +128,6 @@ static void DefaultOptions(RemoteAuthenticationOptions options, string provider)
builder.Services.AddScoped<IApiTokenService, ApiTokenService>();
builder.Services.AddScoped<ILCGNodeProvisioner, LCGNodeProvisioner>();

builder.AddSwaggerExt<Program>();

builder.AddCloudflareTurnstileService();

builder.Services.AddHostedService<RedisSubscriberService>();
Expand Down
3 changes: 2 additions & 1 deletion Common/Common.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
</PackageReference>
<PackageReference Include="MessagePack" />
<PackageReference Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
<PackageReference Include="Microsoft.Extensions.Caching.Hybrid" />
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
Expand All @@ -33,11 +34,11 @@
<PackageReference Include="Scrutor" />
<PackageReference Include="Serilog.Sinks.Grafana.Loki" />
<PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" />
<PackageReference Include="Asp.Versioning.OpenApi" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.StackExchangeRedis" />
<PackageReference Include="OneOf" />
<PackageReference Include="Serilog.AspNetCore" />
<PackageReference Include="Serilog.Sinks.OpenTelemetry" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerGen" />
<PackageReference Include="Z.EntityFramework.Plus.EFCore" />
</ItemGroup>

Expand Down
2 changes: 1 addition & 1 deletion Common/DataAnnotations/EmailAddressAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ public void Apply(OpenApiSchema schema)
{
//if (ShouldValidate) schema.Pattern = ???;

schema.Example = JsonValue.Create(ExampleValue);
schema.Examples = [JsonValue.Create(ExampleValue)];
}

/// <inheritdoc/>
Expand Down
6 changes: 3 additions & 3 deletions Common/DataAnnotations/OpenApiSchemas.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ public static class OpenApiSchemas
public static OpenApiSchema SemVerSchema => new OpenApiSchema {
Title = "SemVer",
Type = JsonSchemaType.String,
Pattern = /* lang=regex */ "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?$",
Example = JsonValue.Create("1.0.0-dev+a16f2")
Pattern = /* lang=regex */ @"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$",
Examples = [JsonValue.Create("1.0.0-dev+a16f2")]
};

public static OpenApiSchema PauseReasonEnumSchema => new OpenApiSchema {
Expand All @@ -26,6 +26,6 @@ public static class OpenApiSchemas

For example, a value of 6 (2 | 4) indicates both 'UserShare' and 'PublicShare' reasons.
""",
Example = JsonValue.Create(6)
Examples = [JsonValue.Create(6)]
};
}
4 changes: 2 additions & 2 deletions Common/DataAnnotations/PasswordAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ public sealed class PasswordAttribute : ValidationAttribute, IParameterAttribute
/// <summary>
/// Example value used to generate OpenApi documentation.
/// </summary>
private const string ExampleValue = "user@example.com";
private const string ExampleValue = "Password123!";

private const string ErrMsgCannotBeNull = "Password cannot be null";
private const string ErrMsgMustBeString = "Password must be a string";
Expand Down Expand Up @@ -62,7 +62,7 @@ public void Apply(OpenApiSchema schema)
{
//if (ShouldValidate) schema.Pattern = ???;

schema.Example = JsonValue.Create(ExampleValue);
schema.Examples = [JsonValue.Create(ExampleValue)];
}

/// <inheritdoc/>
Expand Down
2 changes: 1 addition & 1 deletion Common/DataAnnotations/UsernameAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ public void Apply(OpenApiSchema schema)
{
//if (ShouldValidate) schema.Pattern = ???;

schema.Example = JsonValue.Create(ExampleValue);
schema.Examples = [JsonValue.Create(ExampleValue)];
}

/// <inheritdoc/>
Expand Down
51 changes: 51 additions & 0 deletions Common/OpenApi/ControllerTagDescriptionTransformer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
using Microsoft.AspNetCore.Http.Metadata;
using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.AspNetCore.OpenApi;
using Microsoft.OpenApi;

namespace OpenShock.Common.OpenApi;

/// <summary>
/// Restores controller class &lt;summary&gt; text as top-level tag descriptions,
/// which Swashbuckle's IncludeXmlComments(..., includeControllerXmlComments: true) used to do.
/// </summary>
public sealed class ControllerTagDescriptionTransformer(DocumentedXmlComments comments) : IOpenApiDocumentTransformer
{
public Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerContext context, CancellationToken cancellationToken)
{
var descriptions = new Dictionary<string, string>();

foreach (var description in context.DescriptionGroups.SelectMany(g => g.Items))
{
if (description.ActionDescriptor is not ControllerActionDescriptor controller) continue;

var summary = comments.GetSummary(controller.ControllerTypeInfo);
if (summary.Length == 0) continue;

// Operations are tagged by [Tags] when present, otherwise by controller name
var tagNames = controller.EndpointMetadata.OfType<ITagsMetadata>().SelectMany(t => t.Tags).ToArray();
if (tagNames.Length == 0) tagNames = [controller.ControllerName];

foreach (var tagName in tagNames)
{
descriptions.TryAdd(tagName, summary);
}
}

var tags = new SortedDictionary<string, OpenApiTag>(StringComparer.Ordinal);
foreach (var tag in document.Tags ?? Enumerable.Empty<OpenApiTag>())
{
if (tag.Name is not null) tags.TryAdd(tag.Name, tag);
}

// Only describe tags operations actually use (or that already exist); never invent new ones
foreach (var (name, summary) in descriptions)
{
if (tags.TryGetValue(name, out var existing)) existing.Description ??= summary;
}

document.Tags = new HashSet<OpenApiTag>(tags.Values);

return Task.CompletedTask;
}
}
40 changes: 40 additions & 0 deletions Common/OpenApi/DocumentedResponsesTransformer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.AspNetCore.OpenApi;
using Microsoft.OpenApi;

namespace OpenShock.Common.OpenApi;

/// <summary>
/// Restores &lt;response code="..."&gt; XML docs as responses, including codes that have no [ProducesResponseType],
/// which Swashbuckle's XML comments filter used to add.
/// </summary>
public sealed class DocumentedResponsesTransformer(DocumentedXmlComments comments) : IOpenApiOperationTransformer
{
public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransformerContext context, CancellationToken cancellationToken)
{
if (context.Description.ActionDescriptor is not ControllerActionDescriptor { MethodInfo: var method }) return Task.CompletedTask;

var codes = comments.FindMember(method)?.Elements("response").Select(r => r.Attribute("code")?.Value);
if (codes is null) return Task.CompletedTask;

foreach (var code in codes)
{
if (string.IsNullOrEmpty(code)) continue;

var description = comments.GetResponseDescription(method, code);
if (description.Length == 0) continue;

operation.Responses ??= new OpenApiResponses();
if (operation.Responses.TryGetValue(code, out var existing))
{
if (existing is OpenApiResponse response) response.Description = description;
}
else
{
operation.Responses[code] = new OpenApiResponse { Description = description };
}
}

return Task.CompletedTask;
}
}
100 changes: 100 additions & 0 deletions Common/OpenApi/EnumSchemaTransformer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.OpenApi;
using Microsoft.OpenApi;
using NpgsqlTypes;

namespace OpenShock.Common.OpenApi;

/// <summary>
/// Handles string enums that the schema exporter cannot describe on its own, mainly enums written by a custom converter
/// (which the exporter emits as an empty schema, dropping e.g. the <c>items</c> of a collection of them).
/// <list type="bullet">
/// <item><see cref="CreateItemsSchema"/> stands in for the dropped schema while schemas are being generated.</item>
/// <item>As a document transformer it then registers a component per enum and swaps those stand-ins for a <c>$ref</c>.
/// This cannot happen during generation, because the generator cannot unwrap a reference that has no target yet.</item>
/// <item>It also gives enum components that ended up without a <c>type</c> their <c>string</c> type.</item>
/// </list>
/// </summary>
public sealed class EnumSchemaTransformer : IOpenApiDocumentTransformer
{
/// <summary>
/// Stand-in schemas handed out by <see cref="CreateItemsSchema"/>, mapped to the enum they describe.
/// </summary>
private static readonly ConditionalWeakTable<OpenApiSchema, Type> StandIns = new();

/// <summary>
/// Whether <paramref name="propertyType"/> is a collection of enums, and if so which one.
/// </summary>
public static bool TryGetEnumElementType(Type propertyType, out Type enumType)
{
var element = propertyType.IsArray ? propertyType.GetElementType()
: propertyType.IsGenericType ? propertyType.GetGenericArguments()[0]
: null;

enumType = element!;
return element is { IsEnum: true };
}

/// <summary>
/// Creates the schema for a string enum, named by <see cref="JsonStringEnumMemberNameAttribute"/>,
/// then <see cref="PgNameAttribute"/>, then the member name.
/// </summary>
public static OpenApiSchema CreateSchema(Type enumType) => new()
{
Type = JsonSchemaType.String,
Enum = enumType.GetFields(BindingFlags.Public | BindingFlags.Static)
.Select(f => f.GetCustomAttribute<JsonStringEnumMemberNameAttribute>()?.Name ?? f.GetCustomAttribute<PgNameAttribute>()?.PgName ?? f.Name)
.Select(JsonNode (name) => JsonValue.Create(name))
.ToList()
};

/// <summary>
/// Gives a string enum written by a custom converter, which comes out without a type, its <c>string</c> type.
/// </summary>
public static void NormalizeType(OpenApiSchema schema)
{
if (schema.Type is not null || schema.Enum is not { Count: > 0 } enumValues) return;

// Nullable enums carry a real null entry, which the IList<JsonNode> annotation does not admit
IEnumerable<JsonNode?> values = enumValues;
if (values.Any(v => v is not null && v.GetValueKind() != JsonValueKind.String)) return;

schema.Type = values.Any(v => v is null) ? JsonSchemaType.String | JsonSchemaType.Null : JsonSchemaType.String;
}

/// <summary>
/// Creates an inline schema for use as <c>items</c> during schema generation; it is replaced by a reference in <see cref="TransformAsync"/>.
/// </summary>
public static OpenApiSchema CreateItemsSchema(Type enumType)
{
var schema = CreateSchema(enumType);
StandIns.Add(schema, enumType);
return schema;
}

public Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerContext context, CancellationToken cancellationToken)
{
var components = document.Components ??= new OpenApiComponents();
var schemas = components.Schemas ??= new Dictionary<string, IOpenApiSchema>();

foreach (var schema in schemas.Values.OfType<OpenApiSchema>().ToArray())
{
// Enums only reached through another schema (e.g. a nullable wrapper) never go through the schema transformer
NormalizeType(schema);

foreach (var property in schema.Properties?.Values.OfType<OpenApiSchema>() ?? [])
{
if (property.Items is not OpenApiSchema items || !StandIns.TryGetValue(items, out var enumType)) continue;

schemas[enumType.Name] = CreateSchema(enumType);
property.Items = new OpenApiSchemaReference(enumType.Name, document);
}
}

return Task.CompletedTask;
}
}
41 changes: 41 additions & 0 deletions Common/OpenApi/NullableReferenceTransformer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.OpenApi;
using Microsoft.OpenApi;

namespace OpenShock.Common.OpenApi;

/// <summary>
/// A nullable reference to a component is exported as <c>oneOf: [null, $ref]</c>. Swashbuckle emitted the bare <c>$ref</c>,
/// so generated clients keep their non-nullable types. Wrappers that carry their own metadata are left alone.
/// </summary>
public sealed class NullableReferenceTransformer : IOpenApiDocumentTransformer
{
public Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerContext context, CancellationToken cancellationToken)
{
foreach (var schema in document.Components?.Schemas?.Values.OfType<OpenApiSchema>().ToArray() ?? [])
{
if (schema.Properties is not { } properties) continue;

foreach (var (name, property) in properties.ToArray())
{
if (property is not OpenApiSchema { OneOf: { Count: 2 } oneOf, Description: null } wrapper) continue;
if (wrapper.Type is not null && wrapper.Type != JsonSchemaType.Null) continue;

var reference = oneOf.OfType<OpenApiSchemaReference>().SingleOrDefault();
if (reference is null || !oneOf.OfType<OpenApiSchema>().Any(IsNullSchema)) continue;

properties[name] = reference;
}
}

return Task.CompletedTask;
}

private static bool IsNullSchema(OpenApiSchema schema)
{
if (schema.Type == JsonSchemaType.Null) return true;

IEnumerable<JsonNode?>? values = schema.Enum;
return values is not null && values.Count() == 1 && values.Single() is null;
}
}
Loading
Loading