Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

openapi: include headers #1459

Merged
merged 36 commits into from
Feb 23, 2024
Merged
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
cda34c7
openapi: include ETag/Location headers
verdie-g Feb 7, 2024
60e31b6
Update all open api files
verdie-g Feb 8, 2024
baa8677
Make response headers required
verdie-g Feb 8, 2024
07d4477
Fix coding style
verdie-g Feb 8, 2024
06a4d79
Fix test
verdie-g Feb 8, 2024
36d5ff7
Add schema to headers
verdie-g Feb 8, 2024
0a6c833
Fix test
verdie-g Feb 8, 2024
31b3b3a
Use WrapResponses option for NSwag
verdie-g Feb 8, 2024
f371aa5
Document 304 + If-None-Match
verdie-g Feb 8, 2024
4992baa
Stuff
verdie-g Feb 8, 2024
61dd786
Add content length header
verdie-g Feb 12, 2024
e34b303
Idk anymore
verdie-g Feb 14, 2024
056cc82
Address PR comments
verdie-g Feb 17, 2024
ad8ef94
Remove dead code
verdie-g Feb 17, 2024
f66b8ed
Use primary constructor
verdie-g Feb 17, 2024
69930d1
Address easy PR comments
verdie-g Feb 18, 2024
8c265c7
Rewrite e2e tests
verdie-g Feb 18, 2024
456a78c
Address PR comments (1/2) - will not compile
verdie-g Feb 19, 2024
d4ad3a6
Address PR comments (2/2)
verdie-g Feb 19, 2024
d5b7a40
Add HeaderTests
verdie-g Feb 21, 2024
1a8cc52
Fix inspection
verdie-g Feb 21, 2024
09338e1
Address PR comments
verdie-g Feb 21, 2024
0a6f86f
Fix inspection
verdie-g Feb 21, 2024
1b73bfb
Update src/JsonApiDotNetCore.OpenApi/SwaggerComponents/JsonApiOperati…
verdie-g Feb 23, 2024
4d805f1
Update src/JsonApiDotNetCore.OpenApi.Client/ApiResponse.cs
verdie-g Feb 23, 2024
8e21d98
Update test/OpenApiEndToEndTests/Headers/ETagTests.cs
verdie-g Feb 23, 2024
ef7f78b
Update test/OpenApiEndToEndTests/Headers/ETagTests.cs
verdie-g Feb 23, 2024
e083378
Update test/OpenApiTests/Headers/HeaderTests.cs
verdie-g Feb 23, 2024
e9899d9
Update test/OpenApiEndToEndTests/Headers/ETagTests.cs
verdie-g Feb 23, 2024
1d43181
Update test/OpenApiEndToEndTests/Headers/ETagTests.cs
verdie-g Feb 23, 2024
6611efb
Update test/OpenApiEndToEndTests/Headers/ETagTests.cs
verdie-g Feb 23, 2024
f8e8912
Address PR comments
verdie-g Feb 23, 2024
c22b8d1
Fix stuff
verdie-g Feb 23, 2024
22fa001
Suggestion to update OpenAPI docs for headers usage
bkoelman Feb 23, 2024
0eae574
Address PR comments
verdie-g Feb 23, 2024
a9f15f5
Add missing line break
bkoelman Feb 23, 2024
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

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@

<ItemGroup>
<OpenApiReference Include="..\JsonApiDotNetCoreExample\GeneratedSwagger\JsonApiDotNetCoreExample.json" CodeGenerator="NSwagCSharp" ClassName="ExampleApiClient">
<Options>/GenerateExceptionClasses:false /AdditionalNamespaceUsages:JsonApiDotNetCore.OpenApi.Client.Exceptions</Options>
<Options>/GenerateExceptionClasses:false /WrapResponses:true /GenerateResponseClasses:false /ResponseClass:ApiResponse /AdditionalNamespaceUsages:JsonApiDotNetCore.OpenApi.Client</Options>
</OpenApiReference>
</ItemGroup>
</Project>
29 changes: 21 additions & 8 deletions src/Examples/JsonApiDotNetCoreExampleClient/Program.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
using System.Net;
using JsonApiDotNetCore.OpenApi.Client;
using JsonApiDotNetCoreExampleClient;
using Microsoft.Net.Http.Headers;

#if DEBUG
using var httpClient = new HttpClient(new ColoredConsoleLogDelegatingHandler
Expand All @@ -12,17 +14,17 @@

var apiClient = new ExampleApiClient(httpClient);

PersonCollectionResponseDocument getResponse = await apiClient.GetPersonCollectionAsync(new Dictionary<string, string?>
ApiResponse<PersonCollectionResponseDocument?> getResponse1 = await GetPersonCollectionAsync(apiClient, null);
ApiResponse<PersonCollectionResponseDocument?> getResponse2 = await GetPersonCollectionAsync(apiClient, getResponse1.Headers[HeaderNames.ETag].First());

if (getResponse2 is { StatusCode: (int)HttpStatusCode.NotModified, Result: null })
{
["filter"] = "has(assignedTodoItems)",
["sort"] = "-lastName",
["page[size]"] = "5",
["include"] = "assignedTodoItems.tags"
});
Console.WriteLine("The HTTP response hasn't changed, so no response body was returned.");
}

foreach (PersonDataInResponse person in getResponse.Data)
foreach (PersonDataInResponse person in getResponse1.Result!.Data)
{
PrintPerson(person, getResponse.Included);
PrintPerson(person, getResponse1.Result.Included);
}

var patchRequest = new PersonPatchRequestDocument
Expand All @@ -47,6 +49,17 @@
Console.WriteLine("Press any key to close.");
Console.ReadKey();

static Task<ApiResponse<PersonCollectionResponseDocument?>> GetPersonCollectionAsync(ExampleApiClient apiClient, string? ifNoneMatch)
{
return ApiResponse.TranslateAsync(() => apiClient.GetPersonCollectionAsync(new Dictionary<string, string?>
{
["filter"] = "has(assignedTodoItems)",
["sort"] = "-lastName",
["page[size]"] = "5",
["include"] = "assignedTodoItems.tags"
}, ifNoneMatch));
}

static void PrintPerson(PersonDataInResponse person, ICollection<DataInResponse> includes)
{
ToManyTodoItemInResponse assignedTodoItems = person.Relationships.AssignedTodoItems;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
// We cannot rely on generating ApiException as soon as we are generating multiple clients, see https://github.com/RicoSuter/NSwag/issues/2839#issuecomment-776647377.
// Instead, we configure NSwag to point to the exception below in the generated code.

namespace JsonApiDotNetCore.OpenApi.Client.Exceptions;
namespace JsonApiDotNetCore.OpenApi.Client;

[UsedImplicitly(ImplicitUseTargetFlags.Members)]
public class ApiException(string message, int statusCode, string? response, IReadOnlyDictionary<string, IEnumerable<string>> headers, Exception? innerException)
Expand Down
51 changes: 45 additions & 6 deletions src/JsonApiDotNetCore.OpenApi.Client/ApiResponse.cs
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
using System.Net;
using JetBrains.Annotations;
using JsonApiDotNetCore.OpenApi.Client.Exceptions;

#pragma warning disable AV1008 // Class should not be static

namespace JsonApiDotNetCore.OpenApi.Client;

[PublicAPI]
public static class ApiResponse
public class ApiResponse(int statusCode, IReadOnlyDictionary<string, IEnumerable<string>> headers)
{
public int StatusCode { get; private set; } = statusCode;

public IReadOnlyDictionary<string, IEnumerable<string>> Headers { get; private set; } = headers;
verdie-g marked this conversation as resolved.
Show resolved Hide resolved

public static async Task<TResponse?> TranslateAsync<TResponse>(Func<Task<TResponse>> operation)
where TResponse : class
{
Expand All @@ -17,7 +19,7 @@ public static class ApiResponse
{
return await operation().ConfigureAwait(false);
}
catch (ApiException exception) when (exception.StatusCode == 204)
catch (ApiException exception) when (exception.StatusCode is (int)HttpStatusCode.NoContent or (int)HttpStatusCode.NotModified)
{
// Workaround for https://github.com/RicoSuter/NSwag/issues/2499
return null;
Expand All @@ -32,9 +34,46 @@ public static async Task TranslateAsync(Func<Task> operation)
{
await operation().ConfigureAwait(false);
}
catch (ApiException exception) when (exception.StatusCode == 204)
catch (ApiException exception) when (exception.StatusCode is (int)HttpStatusCode.NoContent or (int)HttpStatusCode.NotModified)
{
// Workaround for https://github.com/RicoSuter/NSwag/issues/2499
}
}

public static async Task<ApiResponse<TResult?>> TranslateAsync<TResult>(Func<Task<ApiResponse<TResult>>> operation)
where TResult : class
{
ArgumentGuard.NotNull(operation);

try
{
return (await operation().ConfigureAwait(false))!;
}
catch (ApiException exception) when (exception.StatusCode is (int)HttpStatusCode.NoContent or (int)HttpStatusCode.NotModified)
{
// Workaround for https://github.com/RicoSuter/NSwag/issues/2499
return new ApiResponse<TResult?>(exception.StatusCode, exception.Headers, null);
}
}

public static async Task<ApiResponse> TranslateAsync(Func<Task<ApiResponse>> operation)
{
ArgumentGuard.NotNull(operation);

try
{
return await operation().ConfigureAwait(false);
}
catch (ApiException exception) when (exception.StatusCode is (int)HttpStatusCode.NoContent or (int)HttpStatusCode.NotModified)
{
// Workaround for https://github.com/RicoSuter/NSwag/issues/2499
return new ApiResponse(exception.StatusCode, exception.Headers);
}
}
}

[PublicAPI]
public class ApiResponse<TResult>(int statusCode, IReadOnlyDictionary<string, IEnumerable<string>> headers, TResult result) : ApiResponse(statusCode, headers)
{
public TResult Result { get; private set; } = result;
}
7 changes: 5 additions & 2 deletions src/JsonApiDotNetCore.OpenApi/OpenApiEndpointConvention.cs
Original file line number Diff line number Diff line change
Expand Up @@ -147,8 +147,11 @@ private static IEnumerable<HttpStatusCode> GetSuccessStatusCodesForEndpoint(Json
{
return endpoint switch
{
JsonApiEndpoint.GetCollection or JsonApiEndpoint.GetSingle or JsonApiEndpoint.GetSecondary or JsonApiEndpoint.GetRelationship
=> [HttpStatusCode.OK],
JsonApiEndpoint.GetCollection or JsonApiEndpoint.GetSingle or JsonApiEndpoint.GetSecondary or JsonApiEndpoint.GetRelationship =>
[
HttpStatusCode.OK,
HttpStatusCode.NotModified
],
JsonApiEndpoint.Post =>
[
HttpStatusCode.Created,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using JsonApiDotNetCore.Resources;
using JsonApiDotNetCore.Resources.Annotations;
using Microsoft.AspNetCore.Mvc.ApiExplorer;
using Microsoft.Net.Http.Headers;
using Microsoft.OpenApi.Any;
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;
Expand All @@ -30,6 +31,7 @@ internal sealed class JsonApiOperationDocumentationFilter : IOperationFilter
"Compare the returned ETag HTTP header with an earlier one to determine if the response has changed since it was fetched.";

private const string TextCompletedSuccessfully = "The operation completed successfully.";
private const string TextNotModified = "The fingerprint of the HTTP response matches one of the ETags from the incoming If-None-Match header.";
private const string TextQueryStringBad = "The query string is invalid.";
private const string TextRequestBodyBad = "The request body is missing or malformed.";
private const string TextQueryStringOrRequestBodyBad = "The query string is invalid or the request body is missing or malformed.";
Expand Down Expand Up @@ -162,16 +164,25 @@ private static void ApplyGetPrimary(OpenApiOperation operation, ResourceType res
SetOperationSummary(operation, $"Retrieves a collection of {resourceType} without returning them.");
SetOperationRemarks(operation, TextCompareETag);
SetResponseDescription(operation.Responses, HttpStatusCode.OK, TextCompletedSuccessfully);
SetResponseHeaderETag(operation.Responses, HttpStatusCode.OK);
SetResponseHeaderContentLength(operation.Responses, HttpStatusCode.OK);
SetResponseDescription(operation.Responses, HttpStatusCode.NotModified, TextNotModified);
SetResponseHeaderETag(operation.Responses, HttpStatusCode.NotModified);
}
else
{
SetOperationSummary(operation, $"Retrieves a collection of {resourceType}.");

SetResponseDescription(operation.Responses, HttpStatusCode.OK,
$"Successfully returns the found {resourceType}, or an empty array if none were found.");

SetResponseHeaderETag(operation.Responses, HttpStatusCode.OK);
SetResponseDescription(operation.Responses, HttpStatusCode.NotModified, TextNotModified);
SetResponseHeaderETag(operation.Responses, HttpStatusCode.NotModified);
}

AddQueryStringParameters(operation, false);
AddRequestHeaderIfNoneMatch(operation);
SetResponseDescription(operation.Responses, HttpStatusCode.BadRequest, TextQueryStringBad);
}
else if (operation.Parameters.Count == 1)
Expand All @@ -183,15 +194,23 @@ private static void ApplyGetPrimary(OpenApiOperation operation, ResourceType res
SetOperationSummary(operation, $"Retrieves an individual {singularName} by its identifier without returning it.");
SetOperationRemarks(operation, TextCompareETag);
SetResponseDescription(operation.Responses, HttpStatusCode.OK, TextCompletedSuccessfully);
SetResponseHeaderETag(operation.Responses, HttpStatusCode.OK);
SetResponseHeaderContentLength(operation.Responses, HttpStatusCode.OK);
SetResponseDescription(operation.Responses, HttpStatusCode.NotModified, TextNotModified);
SetResponseHeaderETag(operation.Responses, HttpStatusCode.NotModified);
}
else
{
SetOperationSummary(operation, $"Retrieves an individual {singularName} by its identifier.");
SetResponseDescription(operation.Responses, HttpStatusCode.OK, $"Successfully returns the found {singularName}.");
SetResponseHeaderETag(operation.Responses, HttpStatusCode.OK);
SetResponseDescription(operation.Responses, HttpStatusCode.NotModified, TextNotModified);
SetResponseHeaderETag(operation.Responses, HttpStatusCode.NotModified);
}

SetParameterDescription(operation.Parameters[0], $"The identifier of the {singularName} to retrieve.");
AddQueryStringParameters(operation, false);
AddRequestHeaderIfNoneMatch(operation);
SetResponseDescription(operation.Responses, HttpStatusCode.BadRequest, TextQueryStringBad);
SetResponseDescription(operation.Responses, HttpStatusCode.NotFound, $"The {singularName} does not exist.");
}
Expand All @@ -208,6 +227,8 @@ private void ApplyPostResource(OpenApiOperation operation, ResourceType resource
SetResponseDescription(operation.Responses, HttpStatusCode.Created,
$"The {singularName} was successfully created, which resulted in additional changes. The newly created {singularName} is returned.");

SetResponseHeaderLocation(operation.Responses, HttpStatusCode.Created);

SetResponseDescription(operation.Responses, HttpStatusCode.NoContent,
$"The {singularName} was successfully created, which did not result in additional changes.");

Expand Down Expand Up @@ -271,6 +292,10 @@ relationship is HasOneAttribute

SetOperationRemarks(operation, TextCompareETag);
SetResponseDescription(operation.Responses, HttpStatusCode.OK, TextCompletedSuccessfully);
SetResponseHeaderETag(operation.Responses, HttpStatusCode.OK);
SetResponseHeaderContentLength(operation.Responses, HttpStatusCode.OK);
SetResponseDescription(operation.Responses, HttpStatusCode.NotModified, TextNotModified);
SetResponseHeaderETag(operation.Responses, HttpStatusCode.NotModified);
}
else
{
Expand All @@ -280,10 +305,15 @@ relationship is HasOneAttribute
relationship is HasOneAttribute
? $"Successfully returns the found {rightName}, or <c>null</c> if it was not found."
: $"Successfully returns the found {rightName}, or an empty array if none were found.");

SetResponseHeaderETag(operation.Responses, HttpStatusCode.OK);
SetResponseDescription(operation.Responses, HttpStatusCode.NotModified, TextNotModified);
SetResponseHeaderETag(operation.Responses, HttpStatusCode.NotModified);
}

SetParameterDescription(operation.Parameters[0], $"The identifier of the {singularLeftName} whose related {rightName} to retrieve.");
AddQueryStringParameters(operation, false);
AddRequestHeaderIfNoneMatch(operation);
SetResponseDescription(operation.Responses, HttpStatusCode.BadRequest, TextQueryStringBad);
SetResponseDescription(operation.Responses, HttpStatusCode.NotFound, $"The {singularLeftName} does not exist.");
}
Expand All @@ -303,6 +333,10 @@ relationship is HasOneAttribute

SetOperationRemarks(operation, TextCompareETag);
SetResponseDescription(operation.Responses, HttpStatusCode.OK, TextCompletedSuccessfully);
SetResponseHeaderContentLength(operation.Responses, HttpStatusCode.OK);
SetResponseHeaderETag(operation.Responses, HttpStatusCode.OK);
verdie-g marked this conversation as resolved.
Show resolved Hide resolved
SetResponseDescription(operation.Responses, HttpStatusCode.NotModified, TextNotModified);
SetResponseHeaderETag(operation.Responses, HttpStatusCode.NotModified);
}
else
{
Expand All @@ -313,10 +347,15 @@ relationship is HasOneAttribute
relationship is HasOneAttribute
? $"Successfully returns the found {singularRightName} {ident}, or <c>null</c> if it was not found."
: $"Successfully returns the found {singularRightName} {ident}, or an empty array if none were found.");

SetResponseHeaderETag(operation.Responses, HttpStatusCode.OK);
SetResponseDescription(operation.Responses, HttpStatusCode.NotModified, TextNotModified);
SetResponseHeaderETag(operation.Responses, HttpStatusCode.NotModified);
}

SetParameterDescription(operation.Parameters[0], $"The identifier of the {singularLeftName} whose related {singularRightName} {ident} to retrieve.");
AddQueryStringParameters(operation, true);
AddRequestHeaderIfNoneMatch(operation);
SetResponseDescription(operation.Responses, HttpStatusCode.BadRequest, TextQueryStringBad);
SetResponseDescription(operation.Responses, HttpStatusCode.NotFound, $"The {singularLeftName} does not exist.");
}
Expand Down Expand Up @@ -420,6 +459,58 @@ private static void SetRequestBodyDescription(OpenApiRequestBody requestBody, st
}

private static void SetResponseDescription(OpenApiResponses responses, HttpStatusCode statusCode, string description)
{
OpenApiResponse response = GetOrAddResponse(responses, statusCode);
response.Description = XmlCommentsTextHelper.Humanize(description);
}

private static void SetResponseHeaderETag(OpenApiResponses responses, HttpStatusCode statusCode)
{
OpenApiResponse response = GetOrAddResponse(responses, statusCode);

response.Headers[HeaderNames.ETag] = new OpenApiHeader
{
verdie-g marked this conversation as resolved.
Show resolved Hide resolved
Description = "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.",
Required = true,
Schema = new OpenApiSchema
{
Type = "string"
}
};
}

private static void SetResponseHeaderContentLength(OpenApiResponses responses, HttpStatusCode statusCode)
{
OpenApiResponse response = GetOrAddResponse(responses, statusCode);

response.Headers[HeaderNames.ContentLength] = new OpenApiHeader
{
Description = "Size of the HTTP response body, in bytes.",
Required = true,
Schema = new OpenApiSchema
{
Type = "integer"
verdie-g marked this conversation as resolved.
Show resolved Hide resolved
}
};
}

private static void SetResponseHeaderLocation(OpenApiResponses responses, HttpStatusCode statusCode)
{
OpenApiResponse response = GetOrAddResponse(responses, statusCode);

response.Headers[HeaderNames.Location] = new OpenApiHeader
{
Description = "The URL at which the newly created JSON:API resource can be retrieved.",
verdie-g marked this conversation as resolved.
Show resolved Hide resolved
Required = true,
Schema = new OpenApiSchema
{
Type = "string",
Format = "uri"
}
};
}

private static OpenApiResponse GetOrAddResponse(OpenApiResponses responses, HttpStatusCode statusCode)
{
string responseCode = ((int)statusCode).ToString();

Expand All @@ -429,7 +520,7 @@ private static void SetResponseDescription(OpenApiResponses responses, HttpStatu
responses.Add(responseCode, response);
}

response.Description = XmlCommentsTextHelper.Humanize(description);
return response;
}

private static void AddQueryStringParameters(OpenApiOperation operation, bool isRelationshipEndpoint)
Expand Down Expand Up @@ -462,4 +553,18 @@ private static void AddQueryStringParameters(OpenApiOperation operation, bool is
Description = isRelationshipEndpoint ? RelationshipQueryStringParameters : ResourceQueryStringParameters
});
}

private static void AddRequestHeaderIfNoneMatch(OpenApiOperation operation)
{
operation.Parameters.Add(new OpenApiParameter
{
In = ParameterLocation.Header,
Name = "If-None-Match",
Description = "A list of ETags, resulting in HTTP status 304 without a body, if one of them matches the current fingerprint.",
Schema = new OpenApiSchema
{
Type = "string"
}
});
}
}
Loading