Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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: 1 addition & 0 deletions PTrampert.SimplePatch.OpenApi.Test/GlobalUsings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
global using NUnit.Framework;
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>

<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>

<!-- Building an in-process WebApplication brings in the OpenAPI XML comment source
generator, which the Web SDK would enable for us. -->
<InterceptorsNamespaces>$(InterceptorsNamespaces);Microsoft.AspNetCore.OpenApi.Generated</InterceptorsNamespaces>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.9.0"/>
<PackageReference Include="NUnit" Version="4.6.1" />
<PackageReference Include="NUnit3TestAdapter" Version="6.3.0" />
<PackageReference Include="NUnit.Analyzers" Version="4.14.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="10.0.1"/>
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\PTrampert.SimplePatch.OpenApi\PTrampert.SimplePatch.OpenApi.csproj" />
</ItemGroup>

</Project>
136 changes: 136 additions & 0 deletions PTrampert.SimplePatch.OpenApi.Test/PatchObjectSchemaTransformerTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.OpenApi;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.OpenApi;
using PTrampert.SimplePatch.OpenApi.Test.TestObjects;

namespace PTrampert.SimplePatch.OpenApi.Test;

public class PatchObjectSchemaTransformerTest
{
private const string DefaultPatchSchemaId = "IPatchObjectOfPersonTestModel";

[Test]
public async Task PatchSchema_DescribesThePatchedModelsProperties()
{
var (patchSchema, _) = await GeneratePatchSchemaAsync();

Assert.That(patchSchema.Properties?.Keys,
Is.EquivalentTo(new[] { "name", "dateOfBirth", "email", "nick_name" }));
}

[Test]
public async Task PatchSchema_KeepsTheValidationConstraintsOfThePatchedModel()
{
var (patchSchema, _) = await GeneratePatchSchemaAsync();

var name = patchSchema.Properties!["name"];
Assert.Multiple((Action)(() =>
{
Assert.That(name.MaxLength, Is.EqualTo(255));
Assert.That(name.MinLength, Is.EqualTo(3));
}));
}

[Test]
public async Task PatchSchema_MakesEveryPropertyOptional()
{
var (patchSchema, document) = await GeneratePatchSchemaAsync();

Assert.Multiple((Action)(() =>
{
Assert.That(patchSchema.Required, Is.Null.Or.Empty);
Assert.That(document.Components!.Schemas![nameof(PersonTestModel)].Required, Does.Contain("name"),
"The patched model's own schema must keep its required properties — only the patch body is partial.");
}));
}

[Test]
public async Task PatchSchema_OmitsPropertiesThePatchObjectCannotSet()
{
var (patchSchema, _) = await GeneratePatchSchemaAsync();

Assert.Multiple((Action)(() =>
{
Assert.That(patchSchema.Properties, Does.Not.ContainKey("display"),
"Get-only properties are not patchable.");
Assert.That(patchSchema.Properties, Does.Not.ContainKey("secret"),
"[JsonIgnore] properties are not patchable.");
}));
}

[Test]
public async Task PatchSchema_DescribesItselfAsAPartialUpdate()
{
var (patchSchema, _) = await GeneratePatchSchemaAsync();

Assert.That(patchSchema.Description,
Is.EqualTo("Partial update of PersonTestModel. Omitted properties are left unchanged."));
}

[Test]
public async Task PatchSchema_UsesTheSuppliedExample()
{
var (patchSchema, _) = await GeneratePatchSchemaAsync(options =>
options.Example = _ => new JsonObject { ["name"] = "New Name" });

Assert.That(patchSchema.Example?.ToJsonString(), Is.EqualTo("""{"name":"New Name"}"""));
}

[Test]
public async Task PatchSchema_IsNamedByTheSchemaIdOptionWhenSupplied()
{
var (_, document) = await GeneratePatchSchemaAsync(
options => options.SchemaId = type => $"{type.Name}Patch",
patchSchemaId: "PersonTestModelPatch");

Assert.Multiple((Action)(() =>
{
Assert.That(document.Components!.Schemas!, Does.ContainKey("PersonTestModelPatch"));
Assert.That(document.Components!.Schemas!, Does.Not.ContainKey(DefaultPatchSchemaId));
Assert.That(document.Components!.Schemas!, Does.ContainKey(nameof(PersonTestModel)),
"The schema id override must only apply to patch bodies.");
}));
}

[Test]
public async Task PutBody_IsLeftAlone()
{
var (_, document) = await GeneratePatchSchemaAsync();

var put = document.Paths!["/people/{id}"].Operations![HttpMethod.Put];
var putBody = put.RequestBody!.Content!["application/json"].Schema!;

Assert.That(putBody, Is.InstanceOf<OpenApiSchemaReference>()
.With.Property(nameof(OpenApiSchemaReference.Reference)).Property("Id").EqualTo(nameof(PersonTestModel)));
}

private static async Task<(IOpenApiSchema PatchSchema, OpenApiDocument Document)> GeneratePatchSchemaAsync(
Action<SimplePatchSchemaOptions>? configure = null,
string patchSchemaId = DefaultPatchSchemaId)
{
var builder = WebApplication.CreateBuilder();
builder.WebHost.UseUrls("http://127.0.0.1:0");
builder.Services.ConfigureHttpJsonOptions(options => options.SerializerOptions.AddSimplePatchConverters());
builder.Services.AddOpenApi(options => options.AddSimplePatchSchemas(configure));

await using var app = builder.Build();
app.MapPatch("/people/{id:int}", (int id, IPatchObject<PersonTestModel> patch) => Results.Ok());
app.MapPut("/people/{id:int}", (int id, PersonTestModel person) => Results.Ok());

// Endpoints only reach the document generator once the app has started.
await app.StartAsync();

// AddOpenApi registers one provider per document, keyed by document name.
var document = await app.Services
.GetRequiredKeyedService<IOpenApiDocumentProvider>("v1")
.GetOpenApiDocumentAsync();

await app.StopAsync();

return (document.Components!.Schemas![patchSchemaId], document);
}
}
26 changes: 26 additions & 0 deletions PTrampert.SimplePatch.OpenApi.Test/TestObjects/PersonTestModel.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;

namespace PTrampert.SimplePatch.OpenApi.Test.TestObjects;

public record PersonTestModel
{
[Required]
[StringLength(255, MinimumLength = 3)]
public required string Name { get; init; }

public DateTime DateOfBirth { get; init; }

[EmailAddress]
public string? Email { get; init; }

[JsonPropertyName("nick_name")]
public string? NickName { get; init; }

[JsonIgnore]
public string? Secret { get; init; }

// Get-only, so the generated patch class cannot set it. The source model's schema still
// describes it, marked readOnly.
public string Display => Name;
}
37 changes: 37 additions & 0 deletions PTrampert.SimplePatch.OpenApi/PTrampert.SimplePatch.OpenApi.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<!--
net10.0 only: the transformer needs OpenApiSchemaTransformerContext.GetOrCreateSchemaAsync
to obtain the source model's schema, which .NET 9 does not have. See the OpenAPI proposal
in docs/proposals for the .NET 9 story.
-->
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<Authors>pjt15253</Authors>
<Description>Microsoft.AspNetCore.OpenApi integration for PTrampert.SimplePatch. Documents routes taking an IPatchObject&lt;T&gt; with the patched model's schema instead of an empty object.</Description>
<PackageReadmeFile>README.md</PackageReadmeFile>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<PackageProjectUrl>https://github.com/PaulTrampert/PTrampert.Optionals</PackageProjectUrl>
<PackageReleaseNotes>https://github.com/PaulTrampert/PTrampert.Optionals/releases</PackageReleaseNotes>
<RepositoryUrl>https://github.com/PaulTrampert/PTrampert.Optionals.git</RepositoryUrl>
<IncludeSymbols>true</IncludeSymbols>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
</PropertyGroup>

<ItemGroup>
<None Include="../README.md" Pack="true" PackagePath="" />
</ItemGroup>

<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.12" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\PTrampert.SimplePatch\PTrampert.SimplePatch.csproj" />
</ItemGroup>

</Project>
59 changes: 59 additions & 0 deletions PTrampert.SimplePatch.OpenApi/PatchObjectSchemaTransformer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
using System.Collections.Concurrent;
using Microsoft.AspNetCore.OpenApi;
using Microsoft.OpenApi;

namespace PTrampert.SimplePatch.OpenApi;

/// <summary>
/// Replaces the empty schema the built-in OpenAPI generator produces for an
/// <see cref="IPatchObject{T}"/> request body with the patched model's schema, with every
/// property optional.
/// </summary>
/// <remarks>
/// Register it with <c>AddSimplePatchSchemas</c> rather than directly, so that the schema-id
/// option is wired up too.
/// </remarks>
/// <param name="options">The consumer's adjustments.</param>
public class PatchObjectSchemaTransformer(SimplePatchSchemaOptions options) : IOpenApiSchemaTransformer
{
private static readonly ConcurrentDictionary<Type, HashSet<string>> PatchablePropertyNamesBySourceType = new();

/// <inheritdoc />
public async Task TransformAsync(
OpenApiSchema schema,
OpenApiSchemaTransformerContext context,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);

var type = context.JsonTypeInfo.Type;

// Only the interface is documented; the generated patch class never reaches the document.
if (!type.IsInterface || !type.TryGetPatchSourceType(out var sourceType))
{
return;
}

var source = await context.GetOrCreateSchemaAsync(sourceType, cancellationToken: cancellationToken);

PatchSchemaTransform.Apply(
schema,
source,
sourceType,
GetPatchablePropertyNames(sourceType, context),
options);
}

private static HashSet<string> GetPatchablePropertyNames(Type sourceType, OpenApiSchemaTransformerContext context) =>
PatchablePropertyNamesBySourceType.GetOrAdd(sourceType, _ =>
{
// JsonTypeInfo.Options is the same JsonSerializerOptions the document generator is
// using, so the names here match the ones in the source model's schema exactly —
// naming policy, [JsonPropertyName] and all.
var patchType = PatchClassBuilder.Shared.GetPatchClassFor(sourceType);
return context.JsonTypeInfo.Options
.GetTypeInfo(patchType).Properties
.Select(property => property.Name)
.ToHashSet(StringComparer.Ordinal);
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
using Microsoft.AspNetCore.OpenApi;
using PTrampert.SimplePatch;
using PTrampert.SimplePatch.OpenApi;

// Matches where AddOpenApi lives, so this is in scope wherever it is.
namespace Microsoft.Extensions.DependencyInjection;

/// <summary>
/// Adds SimplePatch's OpenAPI support to the built-in OpenAPI document generator.
/// </summary>
public static class SimplePatchOpenApiOptionsExtensions
{
/// <summary>
/// Documents routes taking an <see cref="IPatchObject{T}"/> request body with the patched
/// model's schema, with every property optional, instead of the empty object the generator
/// produces for the interface.
/// </summary>
/// <example>
/// <code>
/// builder.Services.AddOpenApi(options => options.AddSimplePatchSchemas());
/// </code>
/// </example>
/// <param name="options">The OpenAPI options to add the transformer to.</param>
/// <param name="configure">Optional adjustments to the generated schema.</param>
/// <returns><paramref name="options"/>, for chaining.</returns>
public static OpenApiOptions AddSimplePatchSchemas(
this OpenApiOptions options,
Action<SimplePatchSchemaOptions>? configure = null)
{
ArgumentNullException.ThrowIfNull(options);

var schemaOptions = new SimplePatchSchemaOptions();
configure?.Invoke(schemaOptions);

options.AddSchemaTransformer(new PatchObjectSchemaTransformer(schemaOptions));

if (schemaOptions.SchemaId is { } schemaId)
{
var inner = options.CreateSchemaReferenceId;
options.CreateSchemaReferenceId = jsonTypeInfo =>
jsonTypeInfo.Type.IsInterface && jsonTypeInfo.Type.TryGetPatchSourceType(out var sourceType)
? schemaId(sourceType) ?? inner(jsonTypeInfo)
: inner(jsonTypeInfo);
}

return options;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

<ItemGroup>
<ProjectReference Include="..\PTrampert.SimplePatch\PTrampert.SimplePatch.csproj" />
<ProjectReference Include="..\PTrampert.SimplePatch.Swashbuckle\PTrampert.SimplePatch.Swashbuckle.csproj" />
</ItemGroup>

</Project>
13 changes: 12 additions & 1 deletion PTrampert.SimplePatch.Sample/Program.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Text.Json.Nodes;
using PTrampert.SimplePatch;

var builder = WebApplication.CreateBuilder(args);
Expand All @@ -12,7 +13,17 @@
});
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddSwaggerGen(opts =>
{
// Document IPatchObject<T> request bodies with the patched model's schema.
opts.AddSimplePatchSchemas(patch =>
{
// Worth setting: Swagger UI builds its example from the schema's properties and ignores
// "required", so without this the PATCH example lists every property — which reads as
// "send all of these", and is what "Try it out" would submit.
patch.Example = _ => new JsonObject { ["name"] = "New Name" };
});
});

var app = builder.Build();

Expand Down
1 change: 1 addition & 0 deletions PTrampert.SimplePatch.Swashbuckle.Test/GlobalUsings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
global using NUnit.Framework;
Loading
Loading