diff --git a/OpenAPI.WebApiGenerator.sln b/OpenAPI.WebApiGenerator.sln index 3db1389..4bd6030 100644 --- a/OpenAPI.WebApiGenerator.sln +++ b/OpenAPI.WebApiGenerator.sln @@ -34,6 +34,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Example.OpenApi", "tests\Example.OpenApi\Example.OpenApi.csproj", "{4E274740-E49C-4E56-9B69-C33D9409C119}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenAPI.WebApiGenerator.UnitTests", "tests\OpenAPI.WebApiGenerator.UnitTests\OpenAPI.WebApiGenerator.UnitTests.csproj", "{2CED6DCB-B934-438D-98F0-21C784B353EB}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -188,6 +192,18 @@ Global {4E274740-E49C-4E56-9B69-C33D9409C119}.Release|x64.Build.0 = Release|Any CPU {4E274740-E49C-4E56-9B69-C33D9409C119}.Release|x86.ActiveCfg = Release|Any CPU {4E274740-E49C-4E56-9B69-C33D9409C119}.Release|x86.Build.0 = Release|Any CPU + {2CED6DCB-B934-438D-98F0-21C784B353EB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2CED6DCB-B934-438D-98F0-21C784B353EB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2CED6DCB-B934-438D-98F0-21C784B353EB}.Debug|x64.ActiveCfg = Debug|Any CPU + {2CED6DCB-B934-438D-98F0-21C784B353EB}.Debug|x64.Build.0 = Debug|Any CPU + {2CED6DCB-B934-438D-98F0-21C784B353EB}.Debug|x86.ActiveCfg = Debug|Any CPU + {2CED6DCB-B934-438D-98F0-21C784B353EB}.Debug|x86.Build.0 = Debug|Any CPU + {2CED6DCB-B934-438D-98F0-21C784B353EB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2CED6DCB-B934-438D-98F0-21C784B353EB}.Release|Any CPU.Build.0 = Release|Any CPU + {2CED6DCB-B934-438D-98F0-21C784B353EB}.Release|x64.ActiveCfg = Release|Any CPU + {2CED6DCB-B934-438D-98F0-21C784B353EB}.Release|x64.Build.0 = Release|Any CPU + {2CED6DCB-B934-438D-98F0-21C784B353EB}.Release|x86.ActiveCfg = Release|Any CPU + {2CED6DCB-B934-438D-98F0-21C784B353EB}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/README.md b/README.md index 7940f22..bbc095f 100644 --- a/README.md +++ b/README.md @@ -167,6 +167,26 @@ switch (request.TryMatchAcceptMediaType(out var matchedMediaType } ``` +## Sequential Media Types +OpenAPI 3.2 added support for [sequential media types](https://spec.openapis.org/oas/v3.2.0.html#sequential-media-types). The following sequential media types are supported for both request and response media content: +- application/jsonl +- application/x-ndjson +- application/x-jsonlines +- application/json-seq +- application/geo+json-seq + +Other sequential media types can be implemented by simply following the expected naming convention and placing the implementations in the expected namespace, see the compilation error of any missing media type class. + +### Request Content +Inherit from `SequentialJsonEnumerable` using the following naming convention: +- application/jsonl (lower case) -> `ApplicationJsonlEnumerable` + +### Response Content +Inherit from `SequentialJsonWriter` using the following naming convention: +- application/jsonl (lower case) -> `ApplicationJsonlWriter` + +See the [OpenAPI 3.2 examples](#examples) for further details how to consume and produce sequential media types. + ## Authentication and Authorization OpenAPI defines [security scheme objects](https://spec.openapis.org/oas/latest#security-scheme-object) for authentication and authorization mechanisms. The generator implement endpoint filters that corresponds to the security declaration of each operation. Do _not_ call `UseAuthentication` or similar when configuring the application. diff --git a/src/OpenAPI.WebApiGenerator/ApiGenerator.cs b/src/OpenAPI.WebApiGenerator/ApiGenerator.cs index 776ca45..11bd874 100644 --- a/src/OpenAPI.WebApiGenerator/ApiGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/ApiGenerator.cs @@ -89,9 +89,10 @@ private static void GenerateCode(SourceProductionContext context, var validationExtensionsGenerator = new ValidationExtensionsGenerator(rootNamespace); validationExtensionsGenerator.GenerateClass().AddTo(context); + var sequentialJsonEnumeratorsGenerator = new SequentialMediaTypesGenerator(rootNamespace); + sequentialJsonEnumeratorsGenerator.GenerateClasses().AddTo(context); var operations = new List<(string Namespace, KeyValuePair Operation)>(); - var securityParameterGenerators = new ConcurrentDictionary>(); foreach (var path in openApi.Paths) { var pathExpression = path.Key; @@ -137,9 +138,10 @@ private static void GenerateCode(SourceProductionContext context, var schemaReference = openApiOperationVisitor.GetSchemaReference(mediaType); var typeDeclaration = schemaGenerator.Generate(schemaReference); return new RequestBodyContentGenerator( - pair.Key, + pair, typeDeclaration, - httpRequestExtensionsGenerator); + httpRequestExtensionsGenerator, + sequentialJsonEnumeratorsGenerator); }).ToList(); requestBodyGenerator = new RequestBodyGenerator( body, @@ -164,14 +166,14 @@ private static void GenerateCode(SourceProductionContext context, var responseContent = // OpenAPI.NET is incorrectly adding content where there is none defined. // No content definition means NO content. - response.Content?.Where(content => - openApiResponseVisitor.HasContent(content.Value)) ?? []; - var responseBodyGenerators = responseContent.Select(valuePair => + response.Content?.Where(responseContent => + openApiResponseVisitor.HasContent(responseContent.Value)) ?? []; + var responseBodyGenerators = responseContent.Select(mediaContent => { - var content = valuePair.Value; - var contentSchemaReference = openApiResponseVisitor.GetSchemaReference(content); + var contentMediaType = mediaContent.Value; + var contentSchemaReference = openApiResponseVisitor.GetSchemaReference(contentMediaType); var typeDeclaration = schemaGenerator.Generate(contentSchemaReference); - return new ResponseBodyContentGenerator(valuePair.Key, typeDeclaration); + return new ResponseBodyContentGenerator(mediaContent, typeDeclaration); }).ToList(); var responseHeaderGenerators = response.Headers?.Select(valuePair => diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs index 8fb519a..44500a3 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/AuthGenerator.cs @@ -262,7 +262,7 @@ internal abstract class BaseSecurityRequirementsFilter(WebApiConfiguration confi break; } - authorized &= ClaimContainsScopes(authenticateResult.Principal, configuration.SecuritySchemeOptions.GetScopeOptions(scheme), scopes); + authorized &= ClaimContainsScopes(authenticateResult.Principal, Configuration.SecuritySchemeOptions.GetScopeOptions(scheme), scopes); if (!authorized) break; } diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationGenerator.cs index 4d6929f..7705151 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/OperationGenerator.cs @@ -68,6 +68,18 @@ internal partial class Operation private Func, Response> HandleRequestValidationError { get; } = (_, validationResult) => {{jsonValidationExceptionGenerator.CreateThrowJsonValidationExceptionInvocation("Request is not valid", "validationResult")}}; + /// + /// Create a request validation error response. + /// + /// The invalid request + /// The validation context describing the validation errors + /// + private Response CreateRequestValidationErrorResponse(Request request, ValidationContext validationContext) + { + var configuration = request.HttpContext.RequestServices.GetRequiredService(); + return HandleRequestValidationError(request, validationContext.Results.WithLocation(configuration.OpenApiSpecificationUri)); + } + {{authGenerator.GenerateAuthFilters(operation.Operation, parameters, out var requiresAuth).Indent(4)}} {{(requiresAuth ? """ diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyContentGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyContentGenerator.cs index c6257f7..0e1eeb7 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyContentGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyContentGenerator.cs @@ -1,39 +1,51 @@ -using System.Net.Http.Headers; +using System.Collections.Generic; +using System.Net.Http.Headers; using Corvus.Json.CodeGeneration; using Corvus.Json.CodeGeneration.CSharp; +using Microsoft.OpenApi; using OpenAPI.WebApiGenerator.Extensions; namespace OpenAPI.WebApiGenerator.CodeGeneration; internal sealed class RequestBodyContentGenerator( - string contentType, + KeyValuePair contentMediaType, TypeDeclaration typeDeclaration, - HttpRequestExtensionsGenerator httpRequestExtensionsGenerator) + HttpRequestExtensionsGenerator httpRequestExtensionsGenerator, + SequentialMediaTypesGenerator sequentialMediaTypesGenerator) { - private string FullyQualifiedTypeName => - $"{FullyQualifiedTypeDeclarationIdentifier}?"; - private string FullyQualifiedTypeDeclarationIdentifier => typeDeclaration.FullyQualifiedDotnetTypeName(); - - internal string PropertyName { get; } = contentType.ToPascalCase(); - - internal MediaTypeWithQualityHeaderValue ContentType { get; } = MediaTypeWithQualityHeaderValue.Parse(contentType); + private readonly bool _isSequentialMediaType = contentMediaType.Value.ItemSchema != null; + + internal string PropertyName { get; } = contentMediaType.Key.ToPascalCase(); + internal bool IsPropertyStruct => !_isSequentialMediaType; + + internal MediaTypeWithQualityHeaderValue ContentType { get; } = MediaTypeWithQualityHeaderValue.Parse(contentMediaType.Key); internal string SchemaLocation => typeDeclaration.RelativeSchemaLocation; internal string GenerateRequestBindingDirective() => $""" -{PropertyName} = - ({httpRequestExtensionsGenerator.CreateBindBodyInvocation( - "request", - FullyQualifiedTypeDeclarationIdentifier) - .Indent(8).Trim()}) +{PropertyName} = {(_isSequentialMediaType ? + $"{sequentialMediaTypesGenerator.GenerateConstructorInstance( + ContentType, + typeDeclaration, + "request.Body")}" : + $"({httpRequestExtensionsGenerator.CreateBindBodyInvocation( + "request", + FullyQualifiedTypeDeclarationIdentifier).Indent(8).Trim()})")} """; + - public string GenerateRequestProperty() => - $$""" - /// - /// Request content for {{contentType}} - /// - internal {{FullyQualifiedTypeName}} {{PropertyName}} { get; private set; } - """; + public string GenerateRequestProperty() + { + var fullyQualifiedTypeName = _isSequentialMediaType + ? sequentialMediaTypesGenerator.GetFullyQualifiedTypeName(ContentType, typeDeclaration) + : FullyQualifiedTypeDeclarationIdentifier; + return +$$""" +/// +/// Request content for {{contentMediaType.Key}} +/// +internal {{fullyQualifiedTypeName}}? {{PropertyName}} { get; private set; } +"""; + } } \ No newline at end of file diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyGenerator.cs index d903a0a..a3aa04f 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/RequestBodyGenerator.cs @@ -120,7 +120,7 @@ internal ValidationContext Validate(ValidationContext validationContext, Validat {{{_contentGenerators.AggregateToString(content => $""" true when {content.PropertyName} is not null => - {content.PropertyName}!.Value.Validate("{content.SchemaLocation}", true, validationContext, validationLevel), + {content.PropertyName}!.{(content.IsPropertyStruct ? "Value." : "")}Validate("{content.SchemaLocation}", true, validationContext, validationLevel), """)}} true when requestContentType is null => {{(_body.Required ? """validationContext.WithResult(false, "Request content is missing")""" : "validationContext")}}, diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseBodyContentGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseBodyContentGenerator.cs index b9e3507..22deb5e 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseBodyContentGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseBodyContentGenerator.cs @@ -1,7 +1,9 @@ using System; +using System.Collections.Generic; using System.Net.Http.Headers; using Corvus.Json.CodeGeneration; using Corvus.Json.CodeGeneration.CSharp; +using Microsoft.OpenApi; using OpenAPI.WebApiGenerator.Extensions; namespace OpenAPI.WebApiGenerator.CodeGeneration; @@ -13,62 +15,157 @@ internal sealed class ResponseBodyContentGenerator private readonly MediaTypeHeaderValue _contentType; private readonly TypeDeclaration _typeDeclaration; private readonly bool _isContentTypeRange; - - public ResponseBodyContentGenerator(string contentType, TypeDeclaration typeDeclaration) - { - _contentType = MediaTypeHeaderValue.Parse(contentType); + private readonly bool _isSequentialMediaType; + + public ResponseBodyContentGenerator(KeyValuePair contentMediaType, TypeDeclaration typeDeclaration) + { + _contentType = MediaTypeHeaderValue.Parse(contentMediaType.Key); _typeDeclaration = typeDeclaration; - ClassName = contentType.ToPascalCase(); - - _isContentTypeRange = false; - switch (_contentType.MediaType) + _isSequentialMediaType = contentMediaType.Value.ItemSchema != null; + _isContentTypeRange = _contentType.MediaType.EndsWith("*"); + _contentVariableName = _contentType.MediaType switch { - case "*/*": - _contentVariableName = "any"; - _isContentTypeRange = true; - break; - case not null when _contentType.MediaType.EndsWith("*"): - _contentVariableName = $"any{_contentType.MediaType.TrimEnd('*').TrimEnd('/').ToPascalCase()}"; - _isContentTypeRange = true; - break; - case null: - throw new InvalidOperationException("Content type is null"); - default: - _contentVariableName = _contentType.MediaType.ToCamelCase(); - break; - } + "*/*" => "any", + not null when _isContentTypeRange => + $"any{_contentType.MediaType.TrimEnd('*').TrimEnd('/').ToLower().ToPascalCase()}", + null => throw new InvalidOperationException("Content type is null"), + _ => _contentType.MediaType.ToLower().ToCamelCase() + }; ClassName = _contentVariableName.ToPascalCase(); } private string SchemaLocation => _typeDeclaration.RelativeSchemaLocation; public string GenerateResponseClass(string responseClassName, string contentTypeFieldName) => + _isSequentialMediaType ? $$""" /// /// Response for content {{_contentType}} /// internal sealed class {{ClassName}} : {{responseClassName}} { + private readonly {{ClassName}}Writer<{{_typeDeclaration.FullyQualifiedDotnetTypeName()}}> _content; + private {{_typeDeclaration.FullyQualifiedDotnetTypeName()}}? _currentItem; + private readonly Request _request; + private readonly Operation _operation; + private readonly WebApiConfiguration _configuration; + /// /// Construct response for content {{_contentType}} /// - /// Content{{(_isContentTypeRange ? $""" + /// Request{{(_isContentTypeRange ? +$""" - /// Content type must match range {_contentType.MediaType} - """ : "")}} + /// Content type must match range {_contentType.MediaType} +""" : "")}} + public {{ClassName}}(Request request{{(_isContentTypeRange ? ", string contentType" : "")}}) + {{{(_isContentTypeRange ? +""" + + EnsureExpectedContentType(MediaTypeHeaderValue.Parse(contentType), ContentMediaType); +""" : "")}} + _request = request; + _content = new(request.HttpContext.Response.BodyWriter); + _operation = request.HttpContext.RequestServices.GetRequiredService(); + _configuration = request.HttpContext.RequestServices.GetRequiredService(); + {{contentTypeFieldName}} = {{(_isContentTypeRange ? "contentType" : $"\"{_contentType.MediaType}\"")}}; + } + + /// + /// Write an item to the sequence + /// + /// Item to write + internal void WriteItem({{_typeDeclaration.FullyQualifiedDotnetTypeName()}} item) + { + _currentItem = item; + _operation.Validate(this, _configuration); + + WriteHeaders(_request.HttpContext.Response); + _content.WriteItem(item); + _currentItem = null; + } + + internal static ContentMediaType<{{responseClassName}}> ContentMediaType { get; } = new(MediaTypeHeaderValue.Parse("{{_contentType}}")); + /// + internal override void WriteTo(HttpResponse httpResponse) + { + WriteHeaders(httpResponse); + _content.Dispose(); + } + + private void WriteHeaders(HttpResponse httpResponse) + { + if (!httpResponse.HasStarted) + { + base.WriteTo(httpResponse); + } + } + + private const string ContentSchemaLocation = "{{SchemaLocation}}"; + /// + internal override ValidationContext Validate(ValidationLevel validationLevel) + { + var context = ValidateHeaders(validationLevel); + return ValidateCurrentItem(context, validationLevel); + } + + private ValidationContext ValidateHeaders(ValidationLevel validationLevel) => + _request.HttpContext.Response.HasStarted + ? CreateValidationContext() + : base.Validate(validationLevel); + + private ValidationContext ValidateCurrentItem( + ValidationContext validationContext, + ValidationLevel validationLevel) => + _currentItem is null + ? validationContext + : _content.Validate(_currentItem.Value, ContentSchemaLocation, validationContext, + validationLevel); +} +""" : + + +$$""" +/// +/// Response for content {{_contentType}} +/// +internal sealed class {{ClassName}} : {{responseClassName}} +{ + private {{_typeDeclaration.FullyQualifiedDotnetTypeName()}} _content; + + /// + /// Construct response for content {{_contentType}} + /// + /// Content{{(_isContentTypeRange ? +$""" + + /// Content type must match range {_contentType.MediaType} +""" : "")}} public {{ClassName}}({{_typeDeclaration.FullyQualifiedDotnetTypeName()}} {{_contentVariableName}}{{(_isContentTypeRange ? ", string contentType" : "")}}) {{{(_isContentTypeRange ? """ EnsureExpectedContentType(MediaTypeHeaderValue.Parse(contentType), ContentMediaType); """ : "")}} - Content = {{_contentVariableName}}; + _content = {{_contentVariableName}}; {{contentTypeFieldName}} = {{(_isContentTypeRange ? "contentType" : $"\"{_contentType.MediaType}\"")}}; } internal static ContentMediaType<{{responseClassName}}> ContentMediaType { get; } = new(MediaTypeHeaderValue.Parse("{{_contentType}}")); - protected override IJsonValue Content { get; } - protected override string ContentSchemaLocation { get; } = "{{SchemaLocation}}"; + /// + internal override void WriteTo(HttpResponse httpResponse) + { + base.WriteTo(httpResponse); + httpResponse.WriteResponseBody(_content); + } + + private const string ContentSchemaLocation = "{{SchemaLocation}}"; + /// + internal override ValidationContext Validate(ValidationLevel validationLevel) + { + var validationContext = base.Validate(validationLevel); + return _content.Validate(ContentSchemaLocation, true, validationContext, validationLevel); + } } """; } diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseContentGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseContentGenerator.cs index c46c00e..ce9ca2f 100644 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseContentGenerator.cs +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseContentGenerator.cs @@ -70,10 +70,6 @@ public string GenerateResponseContentClass() }}}{{{(_contentGenerators.Any() ? $$""" - - protected abstract IJsonValue Content { get; } - protected abstract string ContentSchemaLocation { get; } - /// public static ContentMediaType<{{_responseClassName}}>[] ContentMediaTypes { get; } = [{{_contentGenerators.AggregateToString(generator => @@ -114,27 +110,25 @@ internal sealed class ResponseHeaders """ : "")}}} /// internal override void WriteTo(HttpResponse {{{responseVariableName}}}) - {{{{(_contentGenerators.Any() ? -$$""" - - {{HttpResponseExtensionsGenerator.CreateWriteBodyInvocation( - responseVariableName, - "Content")}}; -""" : "")}}} + { {{{responseVariableName}}}.ContentType = {{{contentTypeFieldName}}}; {{{responseVariableName}}}.StatusCode = StatusCode;{{{ _headerGenerators.AggregateToString(generator => - generator.GenerateWriteDirective(responseVariableName)).Indent(8)}}} + generator.GenerateWriteDirective(responseVariableName)).Indent(8) + }}} } + /// + /// Create a validation context + /// + /// Validation context + {{{(_contentGenerators.Any() ? "protected" : "private")}}} ValidationContext CreateValidationContext() => + ValidationContext.ValidContext.UsingStack().UsingResults(); + /// internal override ValidationContext Validate(ValidationLevel validationLevel) { - var validationContext = ValidationContext.ValidContext.UsingStack().UsingResults(); - {{{(_contentGenerators.Any() ? -$""" - validationContext = Content.Validate(ContentSchemaLocation, true, validationContext, validationLevel); -""" : "")}}} + var validationContext = CreateValidationContext(); {{{_headerGenerators.AggregateToString(generator => generator.GenerateValidateDirective()).Indent(8)}}} return validationContext; diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/SequentialMediaTypesGenerator.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/SequentialMediaTypesGenerator.cs new file mode 100644 index 0000000..c9c15f5 --- /dev/null +++ b/src/OpenAPI.WebApiGenerator/CodeGeneration/SequentialMediaTypesGenerator.cs @@ -0,0 +1,293 @@ +using System.Net.Http.Headers; +using Corvus.Json.CodeGeneration; +using Corvus.Json.CodeGeneration.CSharp; +using OpenAPI.WebApiGenerator.Extensions; + +namespace OpenAPI.WebApiGenerator.CodeGeneration; + +internal sealed class SequentialMediaTypesGenerator(string @namespace) +{ + internal string GenerateConstructorInstance( + MediaTypeHeaderValue mediaType, + TypeDeclaration itemTypeDeclaration, + string streamParameterReference) => +$""" +new {GetFullyQualifiedTypeName(mediaType, itemTypeDeclaration)}({streamParameterReference}) +"""; + + internal string GetFullyQualifiedTypeName( + MediaTypeHeaderValue mediaType, + TypeDeclaration itemTypeDeclaration) => + $"{@namespace}.{mediaType.MediaType.ToLower().ToPascalCase()}Enumerable<{itemTypeDeclaration.FullyQualifiedDotnetTypeName()}>"; + + internal SourceCode GenerateClasses() => new("SequentialMediaTypes.g.cs", +$$""" +#nullable enable +using Corvus.Json; +using Microsoft.AspNetCore.Authorization; +using System; +using System.Buffers; +using System.IO.Pipelines; +using System.Text.Json; + +namespace {{@namespace}}; + +/// +/// Base class for sequential json enumerable +/// +internal abstract class SequentialJsonEnumerable(Stream stream) : IAsyncEnumerable<(T, ValidationContext)> + where T : struct, IJsonValue +{ + private int _itemPosition = -1; + private ValidationLevel _validationLevel = default; + private string _schemaLocation = "#"; + private T? _current; + + /// + /// Delimiter between each item + /// + protected abstract byte Delimiter { get; } + + /// + /// Does the sequence require ending with a delimiter? + /// + protected abstract bool RequiresDelimiterAfterLastItem { get; } + + /// + public async IAsyncEnumerator<(T, ValidationContext)> GetAsyncEnumerator(CancellationToken cancellationToken = default) + { + var pipeReader = PipeReader.Create(stream); + try + { + do + { + var result = await pipeReader.ReadAsync(cancellationToken) + .ConfigureAwait(false); + var buffer = result.Buffer; + var position = buffer.PositionOf(Delimiter); + + switch (result.IsCompleted) + { + // Found an item + case false or true when position is not null: + var data = buffer.Slice(0, position.Value); + _itemPosition++; + _current = ParseItem(data); + pipeReader.AdvanceTo(buffer.GetPosition(1, position.Value)); + yield return (_current.Value, ValidateCurrentItem()); + break; + // No more data + case true when buffer.IsEmpty: + yield break; + // No more data to read, data was found, but no delimiter. + // End delimiter is optional, so parse any found data. + case true when !RequiresDelimiterAfterLastItem: + _itemPosition++; + _current = ParseItem(buffer); + pipeReader.AdvanceTo(buffer.End); + yield return (_current.Value, ValidateCurrentItem()); + yield break; + // No more data to read, data was found, but no delimiter. + // End delimiter is required, so discard any found data + case true: + pipeReader.AdvanceTo(buffer.End); + yield break; + // More data exist, and no item found yet + default: + pipeReader.AdvanceTo(buffer.Start, buffer.End); + break; + } + } while (true); + } + finally + { + await pipeReader.CompleteAsync() + .ConfigureAwait(false); + } + } + + /// + /// Parse the read item + /// + /// Data read up until the Delimiter + /// The parsed item + protected abstract T ParseItem(ReadOnlySequence data); + + private ValidationContext ValidateCurrentItem() => + _current?.Validate($"{_schemaLocation}/{_itemPosition}", true, ValidationContext.ValidContext, _validationLevel) ?? ValidationContext.ValidContext; + + /// + /// Validates the sequence + /// + /// The location of the schema describing the sequence + /// Is the sequence required? + /// Current validation context + /// The validation level + /// The validation result + internal ValidationContext Validate(string schemaLocation, bool isRequired, ValidationContext validationContext, ValidationLevel validationLevel) + { + _schemaLocation = schemaLocation; + _validationLevel = validationLevel; + return validationContext; + } +} + +/// +/// Sequential json enumerable for jsonl +/// +internal class ApplicationJsonlEnumerable(Stream stream) : + SequentialJsonEnumerable(stream) + where T : struct, IJsonValue +{ + protected override byte Delimiter => 0x0A; + protected override bool RequiresDelimiterAfterLastItem => false; + protected override T ParseItem(ReadOnlySequence data) => T.Parse(data); +} + +/// +/// Sequential json enumerable for x-ndjson +/// +internal class ApplicationXNdjsonEnumerable(Stream stream) : ApplicationJsonlEnumerable(stream) + where T : struct, IJsonValue; + +/// +/// Sequential json enumerable for x-jsonlines +/// +internal class ApplicationXJsonlinesEnumerable(Stream stream) : ApplicationJsonlEnumerable(stream) + where T : struct, IJsonValue; + +/// +/// Sequential json enumerable for json-seq +/// +internal class ApplicationJsonSeqEnumerable(Stream stream) : + SequentialJsonEnumerable(stream) + where T : struct, IJsonValue +{ + private const byte RecordSeparator = 0x1E; + protected override byte Delimiter => 0x0A; + protected override bool RequiresDelimiterAfterLastItem => true; + + protected override T ParseItem(ReadOnlySequence data) + { + // RS should be first. + // If it is not, then the data is incomplete and invalid, + // let JSON validation handle it + if (!data.IsEmpty && data.FirstSpan[0] == RecordSeparator) + { + data = data.Slice(1); + } + + return T.Parse(data); + } +} + +/// +/// Sequential json enumerable for geo+json-seq +/// +internal class ApplicationGeoJsonSeqEnumerable(Stream stream) : ApplicationJsonSeqEnumerable(stream) + where T : struct, IJsonValue; + + +/// +/// Writer for sequential media types +/// +/// +/// Item type of the sequence +internal abstract class SequentialJsonWriter(PipeWriter writer) : IDisposable + where T : struct, IJsonValue +{ + private readonly Utf8JsonWriter _jsonWriter = new(writer, new JsonWriterOptions + { + // Items should already have been validated so it's + // redundant to validate here again + SkipValidation = true + }); + private int _writtenItems; + + /// + /// Delimiter between each item + /// + protected abstract byte Delimiter { get; } + + /// + /// Optional prefix before each item + /// + protected virtual byte? Prefix { get; } = null; + + /// + /// Validate the item + /// + /// The item to validate + /// Schema location of this sequence + /// Validation context + /// Validation level + /// The validation result + internal ValidationContext Validate(T item, string schemaLocation, ValidationContext validationContext, + ValidationLevel validationLevel) => + item.Validate($"{schemaLocation}/{_writtenItems}", true, validationContext, validationLevel); + + /// + /// Write an item to the sequence + /// + /// Item to write + internal void WriteItem(T item) + { + if (Prefix != null) + { + var prefix = Prefix.Value; + writer.Write(new ReadOnlySpan(ref prefix)); + } + item.WriteTo(_jsonWriter); + _jsonWriter.Flush(); + _jsonWriter.Reset(); + var delimiter = Delimiter; + writer.Write(new ReadOnlySpan(ref delimiter)); + _writtenItems++; + } + + /// + public void Dispose() + { + _jsonWriter.Dispose(); + } +} + +/// +/// Sequential json writer for jsonl +/// +internal class ApplicationJsonlWriter(PipeWriter writer) : SequentialJsonWriter(writer) + where T : struct, IJsonValue +{ + protected override byte Delimiter => 0x0A; +} + +/// +/// Sequential json writer for x-ndjson +/// +internal class ApplicationXNdjsonWriter(PipeWriter writer) : ApplicationJsonlWriter(writer) + where T : struct, IJsonValue; + +/// +/// Sequential json writer for x-jsonlines +/// +internal class ApplicationXJsonlinesWriter(PipeWriter writer) : ApplicationJsonlWriter(writer) + where T : struct, IJsonValue; + +/// +/// Sequential json writer for json-seq +/// +internal class ApplicationJsonSeqWriter(PipeWriter writer) : SequentialJsonWriter(writer) where T : struct, IJsonValue +{ + protected override byte Delimiter => 0x0A; + protected override byte? Prefix => 0x1E; +} + +/// +/// Sequential json writer for geo+json-seq +/// +internal class ApplicationGeoJsonSeqWriter(PipeWriter writer) : ApplicationJsonSeqWriter(writer) + where T : struct, IJsonValue; + +#nullable restore +"""); +} \ No newline at end of file diff --git a/src/OpenAPI.WebApiGenerator/CodeGeneration/TypeSpecification.cs b/src/OpenAPI.WebApiGenerator/CodeGeneration/TypeSpecification.cs deleted file mode 100644 index 65e5f2c..0000000 --- a/src/OpenAPI.WebApiGenerator/CodeGeneration/TypeSpecification.cs +++ /dev/null @@ -1,10 +0,0 @@ -using Microsoft.CodeAnalysis; - -namespace OpenAPI.WebApiGenerator.CodeGeneration; - -internal sealed class TypeSpecification(string name, string @namespace, AdditionalText schema) -{ - public string Name { get; } = name; - public string Namespace { get; } = @namespace; - public AdditionalText Schema { get; } = schema; -} \ No newline at end of file diff --git a/src/OpenAPI.WebApiGenerator/Extensions/StringExtensions.cs b/src/OpenAPI.WebApiGenerator/Extensions/StringExtensions.cs index 20f8620..1f21cc6 100644 --- a/src/OpenAPI.WebApiGenerator/Extensions/StringExtensions.cs +++ b/src/OpenAPI.WebApiGenerator/Extensions/StringExtensions.cs @@ -1,13 +1,12 @@ using System; using System.Diagnostics.CodeAnalysis; using System.Linq; -using System.Text; namespace OpenAPI.WebApiGenerator.Extensions; internal static class StringExtensions { - private static readonly char[] DefaultDelimiters = ['/', '?', '=', '&', '{', '}', '-', '_']; + private static readonly char[] DefaultDelimiters = ['/', '?', '=', '&', '{', '}', '-', '_', '+']; [return: NotNullIfNotNull(nameof(str))] public static string? ToPascalCase(this string? str, params char[] delimiters) diff --git a/src/OpenAPI.WebApiGenerator/OpenAPI.WebApiGenerator.csproj b/src/OpenAPI.WebApiGenerator/OpenAPI.WebApiGenerator.csproj index 9f8f1af..2ab5365 100644 --- a/src/OpenAPI.WebApiGenerator/OpenAPI.WebApiGenerator.csproj +++ b/src/OpenAPI.WebApiGenerator/OpenAPI.WebApiGenerator.csproj @@ -41,6 +41,7 @@ + @@ -61,6 +62,7 @@ + all runtime; build; native; contentfiles; analyzers @@ -76,6 +78,7 @@ + @@ -83,6 +86,6 @@ - + diff --git a/src/OpenAPI.WebApiGenerator/OpenApi/TypeMetadata.cs b/src/OpenAPI.WebApiGenerator/OpenApi/TypeMetadata.cs index 7ac2347..1dd4335 100644 --- a/src/OpenAPI.WebApiGenerator/OpenApi/TypeMetadata.cs +++ b/src/OpenAPI.WebApiGenerator/OpenApi/TypeMetadata.cs @@ -7,6 +7,7 @@ namespace OpenAPI.WebApiGenerator.OpenApi; internal sealed class TypeMetadata(string @namespace, string path, string name) { + private static readonly string[] SchemaMetaLeafNodeNames = ["schema", "itemSchema"]; internal static TypeMetadata From(JsonPointer pointer) { var segments = @@ -20,7 +21,7 @@ internal static TypeMetadata From(JsonPointer pointer) .ToArray() .AsSpan(); // Remove any schema leaf node, as that is metadata and doesn't describe the name of the type - if (segments[^1].Equals("schema", StringComparison.CurrentCultureIgnoreCase)) + if (SchemaMetaLeafNodeNames.Contains(segments[^1], StringComparer.OrdinalIgnoreCase)) { segments = segments[..^1]; } diff --git a/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/V3/OpenApiV3Visitor.OperationVisitor.cs b/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/V3/OpenApiV3Visitor.OperationVisitor.cs index a0686ce..5f367ea 100644 --- a/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/V3/OpenApiV3Visitor.OperationVisitor.cs +++ b/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/V3/OpenApiV3Visitor.OperationVisitor.cs @@ -58,14 +58,11 @@ private void VisitRequestBody() return; } - var requestContentPointer = Visit("requestBody", "content"); foreach (var content in OpenApiDocument.RequestBody.Content) { _requestContentSchemaReferences.Add(content.Value, new JsonReference(Reference.Uri, - requestContentPointer - .Append(content.Key) - .Append("schema") + Visit("requestBody", "content", content.Key, content.Value.ItemSchema is not null ? "itemSchema" : "schema") .ToString() .AsSpan())); } diff --git a/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/V3/OpenApiV3Visitor.ResponseVisitor.cs b/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/V3/OpenApiV3Visitor.ResponseVisitor.cs index 92f6d76..4d3c649 100644 --- a/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/V3/OpenApiV3Visitor.ResponseVisitor.cs +++ b/src/OpenAPI.WebApiGenerator/OpenApi/Visitor/V3/OpenApiV3Visitor.ResponseVisitor.cs @@ -31,7 +31,12 @@ private void VisitContent() foreach (var content in OpenApiDocument.Content) { - if (TryVisit(["content", content.Key, "schema"], out var schemaPointer)) + if (TryVisit(["content", content.Key, "itemSchema"], out var itemSchemaPointer)) + { + _contentReferences.Add(content.Value, new JsonReference(Reference.Uri, + itemSchemaPointer.ToString().AsSpan())); + } + else if (TryVisit(["content", content.Key, "schema"], out var schemaPointer)) { _contentReferences.Add(content.Value, new JsonReference(Reference.Uri, schemaPointer.ToString().AsSpan())); diff --git a/tests/Example.OpenApi32.IntegrationTests/ExportFooEventsTests.cs b/tests/Example.OpenApi32.IntegrationTests/ExportFooEventsTests.cs new file mode 100644 index 0000000..d51216c --- /dev/null +++ b/tests/Example.OpenApi32.IntegrationTests/ExportFooEventsTests.cs @@ -0,0 +1,41 @@ +using System.Net; +using System.Text.Json.Nodes; +using AwesomeAssertions; +using Example.OpenApi32.IntegrationTests.Json; + +namespace Example.OpenApi32.IntegrationTests; + +public class ExportFooEventsTests(FooApplicationFactory app, ITestOutputHelper testOutput) : FooTestSpecification, IClassFixture +{ + [Theory] + [InlineData("application/jsonl")] + [InlineData("application/x-jsonlines")] + [InlineData("application/x-ndjson")] + [InlineData("application/json-seq")] + [InlineData("application/geo+json-seq")] + public async Task ExportingFooEvents_ShouldReturnOkWithSequentialJson(string mediaType) + { + using var client = app.CreateClient(); + var request = new HttpRequestMessage + { + RequestUri = new Uri(client.BaseAddress!, "/foo/1/events"), + Method = HttpMethod.Get + }; + request.Headers.Accept.ParseAdd(mediaType); + + var result = await client.SendAsync(request, CancellationToken); + + result.StatusCode.Should().Be(HttpStatusCode.OK); + result.Content.Headers.ContentType?.MediaType.Should().Be(mediaType); + + var content = await result.Content.ReadAsStringAsync(CancellationToken); + testOutput.WriteLine("Content:"); + testOutput.WriteLine(content); + var lines = content.Split('\n', StringSplitOptions.RemoveEmptyEntries) + .Select(s => s.Trim((char)0x1E)) + .ToArray(); + lines.Should().HaveCount(2); + JsonNode.Parse(lines[0]).GetValue("#/Name").Should().Be("foo1"); + JsonNode.Parse(lines[1]).GetValue("#/Name").Should().Be("foo2"); + } +} \ No newline at end of file diff --git a/tests/Example.OpenApi32.IntegrationTests/FooTestSpecification.cs b/tests/Example.OpenApi32.IntegrationTests/FooTestSpecification.cs index 845dacd..3e4c3a6 100644 --- a/tests/Example.OpenApi32.IntegrationTests/FooTestSpecification.cs +++ b/tests/Example.OpenApi32.IntegrationTests/FooTestSpecification.cs @@ -6,8 +6,8 @@ public abstract class FooTestSpecification { protected CancellationToken CancellationToken { get; } = TestContext.Current.CancellationToken; - protected HttpContent CreateJsonContent(string json) => new StringContent( + protected HttpContent CreateJsonContent(string json, string mediaType = "application/json") => new StringContent( json, encoding: Encoding.UTF8, - mediaType: "application/json"); + mediaType: mediaType); } diff --git a/tests/Example.OpenApi32.IntegrationTests/ImportFooEventsTests.cs b/tests/Example.OpenApi32.IntegrationTests/ImportFooEventsTests.cs new file mode 100644 index 0000000..79de779 --- /dev/null +++ b/tests/Example.OpenApi32.IntegrationTests/ImportFooEventsTests.cs @@ -0,0 +1,36 @@ +using System.Net; +using AwesomeAssertions; +using OpenAPI.IntegrationTestHelpers.Auth; + +namespace Example.OpenApi32.IntegrationTests; + +public class ImportFooEventsTests(FooApplicationFactory app) : FooTestSpecification, IClassFixture +{ + [Theory] + [InlineData("application/jsonl")] + [InlineData("application/x-jsonlines")] + [InlineData("application/x-ndjson")] + [InlineData("application/json-seq", "\x1E")] + [InlineData("application/geo+json-seq", "\x1E")] + public async Task ImportingFooEvents_ShouldReturnAccepted(string mediaType, string? prefix = "") + { + using var client = app.CreateClient() + .WithOAuth2ImplicitFlowAuthentication("update"); + var result = await client.SendAsync(new HttpRequestMessage + { + RequestUri = new Uri(client.BaseAddress!, "/foo/1/events"), + Method = new HttpMethod("POST"), + Content = CreateJsonContent( + $$""" + {{prefix}}{ "Name": "test" } + {{prefix}}{ "Name": "another test" } + + """, mediaType) + }, CancellationToken); + + result.StatusCode.Should().Be(HttpStatusCode.Accepted); + result.Headers.Should().HaveCount(1); + result.Headers.GetValues("ImportedEvents") + .Should().HaveCount(1).And.AllBe("2"); + } +} diff --git a/tests/Example.OpenApi32/Paths/FooFooIdEvents/Get/Operation.Handler.cs b/tests/Example.OpenApi32/Paths/FooFooIdEvents/Get/Operation.Handler.cs new file mode 100644 index 0000000..06eb312 --- /dev/null +++ b/tests/Example.OpenApi32/Paths/FooFooIdEvents/Get/Operation.Handler.cs @@ -0,0 +1,43 @@ +using Example.OpenApi32.Components.Schemas; + +namespace Example.OpenApi32.Paths.FooFooIdEvents.Get; + +internal partial class Operation +{ + internal partial Task HandleAsync(Request request, CancellationToken cancellationToken) + { + switch (request.TryMatchAcceptMediaType(out ContentMediaType? matchedMediaType)) + { + case false: + case true when matchedMediaType == Response.OK200.ApplicationJsonl.ContentMediaType: + var jsonl = new Response.OK200.ApplicationJsonl(request); + WriteItems(jsonl.WriteItem); + return Task.FromResult(jsonl); + case true when matchedMediaType == Response.OK200.ApplicationXJsonlines.ContentMediaType: + var jsonLines = new Response.OK200.ApplicationXJsonlines(request); + WriteItems(jsonLines.WriteItem); + return Task.FromResult(jsonLines); + case true when matchedMediaType == Response.OK200.ApplicationXNdjson.ContentMediaType: + var ndJson = new Response.OK200.ApplicationXNdjson(request); + WriteItems(ndJson.WriteItem); + return Task.FromResult(ndJson); + case true when matchedMediaType == Response.OK200.ApplicationJsonSeq.ContentMediaType: + var jsonSeq = new Response.OK200.ApplicationJsonSeq(request); + WriteItems(jsonSeq.WriteItem); + return Task.FromResult(jsonSeq); + case true when matchedMediaType == Response.OK200.ApplicationGeoJsonSeq.ContentMediaType: + var geoJsonSeq = new Response.OK200.ApplicationGeoJsonSeq(request); + WriteItems(geoJsonSeq.WriteItem); + return Task.FromResult(geoJsonSeq); + default: + throw new NotImplementedException($"Content media type {matchedMediaType} has not been implemented"); + + } + + void WriteItems(Action write) + { + write(FooProperties.Create(name: "foo1")); + write(FooProperties.Create(name: "foo2")); + } + } +} \ No newline at end of file diff --git a/tests/Example.OpenApi32/Paths/FooFooIdEvents/Post/Operation.Handler.cs b/tests/Example.OpenApi32/Paths/FooFooIdEvents/Post/Operation.Handler.cs new file mode 100644 index 0000000..8692acf --- /dev/null +++ b/tests/Example.OpenApi32/Paths/FooFooIdEvents/Post/Operation.Handler.cs @@ -0,0 +1,37 @@ +using Example.OpenApi32.Components.Schemas; + +namespace Example.OpenApi32.Paths.FooFooIdEvents.Post; + +internal partial class Operation +{ + internal partial async Task HandleAsync(Request request, CancellationToken cancellationToken) + { + var content = + request.Body.ApplicationJsonSeq ?? + request.Body.ApplicationGeoJsonSeq ?? + request.Body.ApplicationJsonl ?? + request.Body.ApplicationXNdjson ?? + request.Body.ApplicationXJsonlines as SequentialJsonEnumerable ?? + throw new InvalidOperationException("missing content, this cannot occur"); + + var importedEvents = 0; + await foreach (var (item, validationContext) in content + .WithCancellation(cancellationToken) + .ConfigureAwait(false)) + { + if (!validationContext.IsValid) + { + return CreateRequestValidationErrorResponse(request, validationContext); + } + importedEvents++; + } + + return new Response.Accepted202 + { + Headers = new Response.Accepted202.ResponseHeaders + { + ImportedEvents = importedEvents + } + }; + } +} \ No newline at end of file diff --git a/tests/Example.OpenApi32/openapi.json b/tests/Example.OpenApi32/openapi.json index bce1f03..5d00075 100644 --- a/tests/Example.OpenApi32/openapi.json +++ b/tests/Example.OpenApi32/openapi.json @@ -80,6 +80,95 @@ "$ref": "#/components/parameters/FooId" } ] + }, + "/foo/{FooId}/events": { + "get": { + "operationId": "Export_Foo_Events", + "responses": { + "200": { + "description": "Foo events", + "content": { + "application/jsonl": { + "itemSchema": { + "$ref": "#/components/schemas/FooProperties" + } + }, + "application/x-jsonlines": { + "itemSchema": { + "$ref": "#/components/schemas/FooProperties" + } + }, + "application/x-ndjson": { + "itemSchema": { + "$ref": "#/components/schemas/FooProperties" + } + }, + "application/json-seq": { + "itemSchema": { + "$ref": "#/components/schemas/FooProperties" + } + }, + "application/geo+json-seq": { + "itemSchema": { + "$ref": "#/components/schemas/FooProperties" + } + } + } + } + } + }, + "post": { + "operationId": "Import_Foo_Events", + "requestBody": { + "required": true, + "content": { + "application/jsonl": { + "itemSchema": { + "$ref": "#/components/schemas/FooProperties" + } + }, + "application/x-jsonlines": { + "itemSchema": { + "$ref": "#/components/schemas/FooProperties" + } + }, + "application/x-ndjson": { + "itemSchema": { + "$ref": "#/components/schemas/FooProperties" + } + }, + "application/json-seq": { + "itemSchema": { + "$ref": "#/components/schemas/FooProperties" + } + }, + "application/geo+json-seq": { + "itemSchema": { + "$ref": "#/components/schemas/FooProperties" + } + } + } + }, + "responses": { + "202": { + "description": "Accepted", + "headers": { + "ImportedEvents": { + "description": "Number of events imported", + "schema": { + "type": "integer" + }, + "required": true + } + } + } + } + }, + "parameters": [ + { + "$ref": "#/components/parameters/FooId" + } + ] } }, "components": { diff --git a/tests/OpenAPI.WebApiGenerator.Tests/CodeGeneration/RequestBodyContentGeneratorExtensionTests.cs b/tests/OpenAPI.WebApiGenerator.UnitTests/CodeGeneration/RequestBodyContentGeneratorExtensionTests.cs similarity index 83% rename from tests/OpenAPI.WebApiGenerator.Tests/CodeGeneration/RequestBodyContentGeneratorExtensionTests.cs rename to tests/OpenAPI.WebApiGenerator.UnitTests/CodeGeneration/RequestBodyContentGeneratorExtensionTests.cs index 90a07ce..97ddce2 100644 --- a/tests/OpenAPI.WebApiGenerator.Tests/CodeGeneration/RequestBodyContentGeneratorExtensionTests.cs +++ b/tests/OpenAPI.WebApiGenerator.UnitTests/CodeGeneration/RequestBodyContentGeneratorExtensionTests.cs @@ -1,9 +1,11 @@ +using System.Collections.Generic; using System.Linq; using AwesomeAssertions; +using Microsoft.OpenApi; using OpenAPI.WebApiGenerator.CodeGeneration; using Xunit; -namespace OpenAPI.WebApiGenerator.Tests.CodeGeneration; +namespace OpenAPI.WebApiGenerator.UnitTests.CodeGeneration; public class RequestBodyContentGeneratorExtensionTests { @@ -36,5 +38,5 @@ public void ListOfRequestBodyContentGenerators_SortByContentType_SortsAccordingT } private static RequestBodyContentGenerator CreateGenerator(string contentType) => - new(contentType, null!, null!); + new(new KeyValuePair(contentType, new OpenApiMediaType()), null!, null!, null!); } \ No newline at end of file diff --git a/tests/OpenAPI.WebApiGenerator.Tests/Extensions/MediaTypeExtensionsTests.cs b/tests/OpenAPI.WebApiGenerator.UnitTests/Extensions/MediaTypeExtensionsTests.cs similarity index 98% rename from tests/OpenAPI.WebApiGenerator.Tests/Extensions/MediaTypeExtensionsTests.cs rename to tests/OpenAPI.WebApiGenerator.UnitTests/Extensions/MediaTypeExtensionsTests.cs index 2a1a976..f123f42 100644 --- a/tests/OpenAPI.WebApiGenerator.Tests/Extensions/MediaTypeExtensionsTests.cs +++ b/tests/OpenAPI.WebApiGenerator.UnitTests/Extensions/MediaTypeExtensionsTests.cs @@ -3,7 +3,7 @@ using OpenAPI.WebApiGenerator.Extensions; using Xunit; -namespace OpenAPI.WebApiGenerator.Tests.Extensions; +namespace OpenAPI.WebApiGenerator.UnitTests.Extensions; public class MediaTypeExtensionsTests { diff --git a/tests/OpenAPI.WebApiGenerator.UnitTests/OpenAPI.WebApiGenerator.UnitTests.csproj b/tests/OpenAPI.WebApiGenerator.UnitTests/OpenAPI.WebApiGenerator.UnitTests.csproj new file mode 100644 index 0000000..4c72623 --- /dev/null +++ b/tests/OpenAPI.WebApiGenerator.UnitTests/OpenAPI.WebApiGenerator.UnitTests.csproj @@ -0,0 +1,26 @@ + + + + net9.0 + enable + false + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + \ No newline at end of file