Skip to content

Commit 0cd9592

Browse files
authored
Merge pull request #32 from Fresa/fix-content-negotiation-order
Fix content negotiation order
2 parents a1f64e7 + 8e0fb11 commit 0cd9592

16 files changed

Lines changed: 108 additions & 48 deletions

File tree

.github/workflows/cd.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,10 @@ jobs:
2222
matrix:
2323
os: [ubuntu-latest]
2424
steps:
25-
- uses: actions/checkout@v5
25+
- uses: actions/checkout@v6
2626
- name: Setup .NET
2727
id: dotnet
28-
uses: actions/setup-dotnet@v4
28+
uses: actions/setup-dotnet@v5
2929
with:
3030
dotnet-version: ${{ env.dotnet_version }}
3131
- name: Build
@@ -40,7 +40,7 @@ jobs:
4040

4141
release:
4242
name: Create Release
43-
uses: Fresa/Library.Net.ContinuousDelivery/.github/workflows/release.yml@v0
43+
uses: Fresa/Library.Net.ContinuousDelivery/.github/workflows/release.yml@v1
4444
needs: test
4545
if: github.repository == needs.test.outputs.repository && github.actor != 'dependabot[bot]'
4646
permissions:

.github/workflows/lint-openapi.yml

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,12 @@ jobs:
1111
lint:
1212
runs-on: ubuntu-latest
1313
steps:
14-
- uses: actions/checkout@v4
14+
- uses: actions/checkout@v6
1515

1616
- name: Install vacuum
17-
run: curl -fsSL https://quobix.com/scripts/install_vacuum.sh | sh
17+
run: curl -fsSL https://quobix.com/scripts/install_vacuum.sh | VERSION=0.29.1 sh
1818

1919
- name: Lint OpenAPI specs
20-
run: vacuum lint tests/{*/,*/*/}openapi*.{json,yaml}
20+
run: |
21+
shopt -s nullglob
22+
vacuum lint tests/{*/,*/*/}openapi*.{json,yaml}

src/OpenAPI.WebApiGenerator/ApiGenerator.cs

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -169,12 +169,14 @@ private static void GenerateCode(SourceProductionContext context,
169169
response.Content?.Where(responseContent =>
170170
openApiResponseVisitor.HasContent(responseContent.Value)) ?? [];
171171
var responseBodyGenerators = responseContent.Select(mediaContent =>
172-
{
173-
var contentMediaType = mediaContent.Value;
174-
var contentSchemaReference = openApiResponseVisitor.GetSchemaReference(contentMediaType);
175-
var typeDeclaration = schemaGenerator.Generate(contentSchemaReference);
176-
return new ResponseBodyContentGenerator(mediaContent, typeDeclaration);
177-
}).ToList();
172+
{
173+
var contentMediaType = mediaContent.Value;
174+
var contentSchemaReference = openApiResponseVisitor.GetSchemaReference(contentMediaType);
175+
var typeDeclaration = schemaGenerator.Generate(contentSchemaReference);
176+
return new ResponseBodyContentGenerator(mediaContent, typeDeclaration);
177+
})
178+
.OrderByDescending(generator => generator.ContentType.GetPrecedence())
179+
.ToList();
178180

179181
var responseHeaderGenerators = response.Headers?.Select(valuePair =>
180182
{

src/OpenAPI.WebApiGenerator/CodeGeneration/HttpRequestExtensionsGenerator.cs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -168,9 +168,10 @@ private static bool TryParse<T>(StringValues values, IParameter parameter, [NotN
168168
}
169169
170170
var parser = GetParser(parameter);
171-
var stringValue = parser.ValueIncludesParameterName
172-
? string.Join('&', values.Select(value => $"{parameter.Name}={value}"))
173-
: values.Single();
171+
var stringValue = string.Join(parser.Delimiter,
172+
parser.ValueIncludesParameterName
173+
? values.Select(value => $"{parameter.Name}={value}")
174+
: values);
174175
175176
value = Parse<T>(parser, stringValue);
176177
return true;

src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseBodyContentGenerator.cs

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -12,24 +12,24 @@ internal sealed class ResponseBodyContentGenerator
1212
{
1313
private readonly string _contentVariableName;
1414
internal string ClassName { get; }
15-
private readonly MediaTypeHeaderValue _contentType;
15+
internal MediaTypeHeaderValue ContentType { get; }
1616
private readonly TypeDeclaration _typeDeclaration;
1717
private readonly bool _isContentTypeRange;
1818
private readonly bool _isSequentialMediaType;
1919

2020
public ResponseBodyContentGenerator(KeyValuePair<string, IOpenApiMediaType> contentMediaType, TypeDeclaration typeDeclaration)
2121
{
22-
_contentType = MediaTypeHeaderValue.Parse(contentMediaType.Key);
22+
ContentType = MediaTypeHeaderValue.Parse(contentMediaType.Key);
2323
_typeDeclaration = typeDeclaration;
2424
_isSequentialMediaType = contentMediaType.Value.ItemSchema != null;
25-
_isContentTypeRange = _contentType.MediaType.EndsWith("*");
26-
_contentVariableName = _contentType.MediaType switch
25+
_isContentTypeRange = ContentType.MediaType.EndsWith("*");
26+
_contentVariableName = ContentType.MediaType switch
2727
{
2828
"*/*" => "any",
2929
not null when _isContentTypeRange =>
30-
$"any{_contentType.MediaType.TrimEnd('*').TrimEnd('/').ToLower().ToPascalCase()}",
30+
$"any{ContentType.MediaType.TrimEnd('*').TrimEnd('/').ToLower().ToPascalCase()}",
3131
null => throw new InvalidOperationException("Content type is null"),
32-
_ => _contentType.MediaType.ToLower().ToCamelCase()
32+
_ => ContentType.MediaType.ToLower().ToCamelCase()
3333
};
3434

3535
ClassName = _contentVariableName.ToPascalCase();
@@ -40,7 +40,7 @@ public string GenerateResponseClass(string responseClassName, string contentType
4040
_isSequentialMediaType ?
4141
$$"""
4242
/// <summary>
43-
/// Response for content {{_contentType}}
43+
/// Response for content {{ContentType}}
4444
/// </summary>
4545
internal sealed class {{ClassName}} : {{responseClassName}}
4646
{
@@ -51,12 +51,12 @@ internal sealed class {{ClassName}} : {{responseClassName}}
5151
private readonly WebApiConfiguration _configuration;
5252
5353
/// <summary>
54-
/// Construct response for content {{_contentType}}
54+
/// Construct response for content {{ContentType}}
5555
/// </summary>
5656
/// <param name="request">Request</param>{{(_isContentTypeRange ?
5757
$"""
5858
59-
/// <param name="contentType">Content type must match range {_contentType.MediaType}</param>
59+
/// <param name="contentType">Content type must match range {ContentType.MediaType}</param>
6060
""" : "")}}
6161
public {{ClassName}}(Request request{{(_isContentTypeRange ? ", string contentType" : "")}})
6262
{{{(_isContentTypeRange ?
@@ -68,7 +68,7 @@ internal sealed class {{ClassName}} : {{responseClassName}}
6868
_content = new(request.HttpContext.Response.BodyWriter);
6969
_operation = request.HttpContext.RequestServices.GetRequiredService<Operation>();
7070
_configuration = request.HttpContext.RequestServices.GetRequiredService<WebApiConfiguration>();
71-
{{contentTypeFieldName}} = {{(_isContentTypeRange ? "contentType" : $"\"{_contentType.MediaType}\"")}};
71+
{{contentTypeFieldName}} = {{(_isContentTypeRange ? "contentType" : $"\"{ContentType.MediaType}\"")}};
7272
}
7373
7474
/// <summary>
@@ -85,7 +85,7 @@ internal void WriteItem({{_typeDeclaration.FullyQualifiedDotnetTypeName()}} item
8585
_currentItem = null;
8686
}
8787
88-
internal static ContentMediaType<{{responseClassName}}> ContentMediaType { get; } = new(MediaTypeHeaderValue.Parse("{{_contentType}}"));
88+
internal static ContentMediaType<{{responseClassName}}> ContentMediaType { get; } = new(MediaTypeHeaderValue.Parse("{{ContentType}}"));
8989
/// <inheritdoc/>
9090
internal override void WriteTo(HttpResponse httpResponse)
9191
{
@@ -127,19 +127,19 @@ _currentItem is null
127127

128128
$$"""
129129
/// <summary>
130-
/// Response for content {{_contentType}}
130+
/// Response for content {{ContentType}}
131131
/// </summary>
132132
internal sealed class {{ClassName}} : {{responseClassName}}
133133
{
134134
private {{_typeDeclaration.FullyQualifiedDotnetTypeName()}} _content;
135135
136136
/// <summary>
137-
/// Construct response for content {{_contentType}}
137+
/// Construct response for content {{ContentType}}
138138
/// </summary>
139139
/// <param name="{{_contentVariableName}}">Content</param>{{(_isContentTypeRange ?
140140
$"""
141141
142-
/// <param name="contentType">Content type must match range {_contentType.MediaType}</param>
142+
/// <param name="contentType">Content type must match range {ContentType.MediaType}</param>
143143
""" : "")}}
144144
public {{ClassName}}({{_typeDeclaration.FullyQualifiedDotnetTypeName()}} {{_contentVariableName}}{{(_isContentTypeRange ? ", string contentType" : "")}})
145145
{{{(_isContentTypeRange ?
@@ -148,10 +148,10 @@ internal sealed class {{ClassName}} : {{responseClassName}}
148148
EnsureExpectedContentType(MediaTypeHeaderValue.Parse(contentType), ContentMediaType);
149149
""" : "")}}
150150
_content = {{_contentVariableName}};
151-
{{contentTypeFieldName}} = {{(_isContentTypeRange ? "contentType" : $"\"{_contentType.MediaType}\"")}};
151+
{{contentTypeFieldName}} = {{(_isContentTypeRange ? "contentType" : $"\"{ContentType.MediaType}\"")}};
152152
}
153153
154-
internal static ContentMediaType<{{responseClassName}}> ContentMediaType { get; } = new(MediaTypeHeaderValue.Parse("{{_contentType}}"));
154+
internal static ContentMediaType<{{responseClassName}}> ContentMediaType { get; } = new(MediaTypeHeaderValue.Parse("{{ContentType}}"));
155155
/// <inheritdoc/>
156156
internal override void WriteTo(HttpResponse httpResponse)
157157
{

src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseContentGenerator.cs

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ internal sealed class ResponseContentGenerator
1414
private readonly string _responseClassName;
1515
private readonly string _responseStatusCodePattern;
1616
private readonly IOpenApiResponse _response;
17+
private readonly bool _hasExplicitStatusCode;
18+
private readonly bool _hasDefaultStatusCode;
1719

1820
private ResponseContentGenerator(
1921
KeyValuePair<string, IOpenApiResponse> response)
@@ -36,6 +38,10 @@ var chr when char.IsDigit(chr) => "X",
3638
_responseStatusCodePattern = responseStatusCodePattern;
3739
_responseClassName = responseClassName;
3840
_response = response.Value;
41+
_hasExplicitStatusCode = int.TryParse(_responseStatusCodePattern, out _);
42+
_hasDefaultStatusCode = _responseStatusCodePattern == "default";
43+
Precedence = _hasExplicitStatusCode ? 0 : _hasDefaultStatusCode ? 10 : 5;
44+
3945
}
4046
public ResponseContentGenerator(
4147
KeyValuePair<string, IOpenApiResponse> response,
@@ -46,6 +52,8 @@ public ResponseContentGenerator(
4652
_headerGenerators = headerGenerators;
4753
}
4854

55+
internal int Precedence { get; }
56+
4957
public string GenerateResponseContentClass()
5058
{
5159
var anyHeaders = _headerGenerators.Any();
@@ -55,9 +63,7 @@ public string GenerateResponseContentClass()
5563
const string responseVariableName = "httpResponse";
5664
const string contentTypeFieldName = "_contentType";
5765

58-
var hasExplicitStatusCode = int.TryParse(_responseStatusCodePattern, out _);
59-
var hasDefaultStatusCode = _responseStatusCodePattern == "default";
60-
var needsStatusCodeValidation = !hasExplicitStatusCode && !hasDefaultStatusCode;
66+
var needsStatusCodeValidation = !_hasExplicitStatusCode && !_hasDefaultStatusCode;
6167

6268
return
6369
$$$"""
@@ -79,13 +85,13 @@ public string GenerateResponseContentClass()
7985
];
8086
""" : "")}}}
8187
82-
private int _statusCode{{{(hasExplicitStatusCode ? $" = {_responseStatusCodePattern}" : string.Empty)}}};
88+
private int _statusCode{{{(_hasExplicitStatusCode ? $" = {_responseStatusCodePattern}" : string.Empty)}}};
8389
/// <summary>
8490
/// Response status code
8591
/// </summary>
8692
internal int StatusCode
8793
{
88-
get => _statusCode;{{{(hasExplicitStatusCode ? "" :
94+
get => _statusCode;{{{(_hasExplicitStatusCode ? "" :
8995
$"""
9096
init => _statusCode = {(needsStatusCodeValidation ? $"Validate{_responseStatusCodePattern.First()}xxStatusCode(value)" : "value")};
9197
""")}}}

src/OpenAPI.WebApiGenerator/CodeGeneration/ResponseGenerator.cs

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -123,13 +123,26 @@ internal bool TryMatchAcceptMediaType<T>(
123123
if ((acceptMediaType.Quality ?? 1.0) <= 0)
124124
continue;
125125
126-
foreach (var mediaType in mediaTypes)
126+
// Exact match
127+
var match = mediaTypes.FirstOrDefault(mediaType =>
128+
acceptMediaType.IsSubsetOf(mediaType.Value) &&
129+
mediaType.Value.IsSubsetOf(acceptMediaType));
130+
131+
// Accept media type is broader than a supported media type;
132+
// */*, application/*, application/*+json -> matches Accept header */*
133+
if (match.Value is null)
134+
match = mediaTypes.FirstOrDefault(mediaType =>
135+
mediaType.Value.IsSubsetOf(acceptMediaType));
136+
137+
// Accept media type fits within a broader supported media type;
138+
// Accept header application/json matches -> */*, application/*
139+
if (match.Value is null)
140+
match = mediaTypes.FirstOrDefault(mediaType =>
141+
acceptMediaType.IsSubsetOf(mediaType.Value));
142+
143+
if (match.Value is not null)
127144
{
128-
if (!mediaType.Value.IsSubsetOf(acceptMediaType))
129-
{
130-
continue;
131-
}
132-
matchedContentMediaType = mediaType;
145+
matchedContentMediaType = match;
133146
return true;
134147
}
135148
}

src/OpenAPI.WebApiGenerator/Extensions/MediaTypeExtensions.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ internal static int GetPrecedence(this MediaTypeHeaderValue value) =>
3434
{
3535
"*/*" => 0,
3636
not null when value.MediaType.EndsWith("*") => 100,
37+
not null when value.MediaType.Contains("+") => 2000,
3738
_ => 1000
3839
};
3940
}

tests/Example.OpenApi20/Example.OpenApi20.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
<PackageReference Include="Corvus.Json.ExtendedTypes" Version="4.4.2" />
1717
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.12" />
1818
<PackageReference Include="Microsoft.DotNet.ApiCompat.Task" Version="10.0.202" PrivateAssets="all" IsImplicitlyDefined="true" />
19-
<PackageReference Include="ParameterStyleParsers.OpenAPI" Version="1.4.0" />
19+
<PackageReference Include="ParameterStyleParsers.OpenAPI" Version="1.5.0" />
2020
</ItemGroup>
2121

2222
<ItemGroup>

tests/Example.OpenApi30/Example.OpenApi30.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
<PackageReference Include="Microsoft.DotNet.ApiCompat.Task" Version="10.0.202" PrivateAssets="all" IsImplicitlyDefined="true" />
1818
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.12" />
1919
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="9.0.12" />
20-
<PackageReference Include="ParameterStyleParsers.OpenAPI" Version="1.4.0" />
20+
<PackageReference Include="ParameterStyleParsers.OpenAPI" Version="1.5.0" />
2121
</ItemGroup>
2222

2323
<ItemGroup>

0 commit comments

Comments
 (0)