Skip to content

Commit 189a0aa

Browse files
committed
Generate documentation for JSON:API resource types and attributes
1 parent 2b27158 commit 189a0aa

File tree

4 files changed

+152
-4
lines changed

4 files changed

+152
-4
lines changed

src/JsonApiDotNetCore.OpenApi/SwaggerComponents/ResourceFieldObjectSchemaBuilder.cs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ internal sealed class ResourceFieldObjectSchemaBuilder
3939
private readonly IDictionary<string, OpenApiSchema> _schemasForResourceFields;
4040
private readonly ResourceFieldValidationMetadataProvider _resourceFieldValidationMetadataProvider;
4141
private readonly RelationshipTypeFactory _relationshipTypeFactory;
42+
private readonly ResourceObjectDocumentationReader _resourceObjectDocumentationReader;
4243

4344
public ResourceFieldObjectSchemaBuilder(ResourceTypeInfo resourceTypeInfo, ISchemaRepositoryAccessor schemaRepositoryAccessor,
4445
SchemaGenerator defaultSchemaGenerator, ResourceTypeSchemaGenerator resourceTypeSchemaGenerator, JsonNamingPolicy? namingPolicy,
@@ -59,6 +60,7 @@ public ResourceFieldObjectSchemaBuilder(ResourceTypeInfo resourceTypeInfo, ISche
5960
_nullableReferenceSchemaGenerator = new NullableReferenceSchemaGenerator(schemaRepositoryAccessor, namingPolicy);
6061
_relationshipTypeFactory = new RelationshipTypeFactory(resourceFieldValidationMetadataProvider);
6162
_schemasForResourceFields = GetFieldSchemas();
63+
_resourceObjectDocumentationReader = new ResourceObjectDocumentationReader();
6264
}
6365

6466
private IDictionary<string, OpenApiSchema> GetFieldSchemas()
@@ -92,6 +94,8 @@ public void SetMembersOfAttributesObject(OpenApiSchema fullSchemaForAttributesOb
9294
{
9395
fullSchemaForAttributesObject.Required.Add(matchingAttribute.PublicName);
9496
}
97+
98+
resourceFieldSchema.Description = _resourceObjectDocumentationReader.GetDocumentationForAttribute(matchingAttribute);
9599
}
96100
}
97101
}
@@ -131,14 +135,18 @@ public void SetMembersOfRelationshipsObject(OpenApiSchema fullSchemaForRelations
131135
{
132136
ArgumentGuard.NotNull(fullSchemaForRelationshipsObject);
133137

134-
foreach (string fieldName in _schemasForResourceFields.Keys)
138+
foreach ((string fieldName, OpenApiSchema resourceFieldSchema) in _schemasForResourceFields)
135139
{
136140
RelationshipAttribute? matchingRelationship = _resourceTypeInfo.ResourceType.FindRelationshipByPublicName(fieldName);
137141

138142
if (matchingRelationship != null)
139143
{
140144
EnsureResourceIdentifierObjectSchemaExists(matchingRelationship);
141145
AddRelationshipSchemaToResourceObject(matchingRelationship, fullSchemaForRelationshipsObject);
146+
147+
// This currently has no effect because $ref cannot be combined with other elements in OAS 3.0.
148+
// This can be worked around by using the allOf operator. See https://github.com/OAI/OpenAPI-Specification/issues/1514.
149+
resourceFieldSchema.Description = _resourceObjectDocumentationReader.GetDocumentationForRelationship(matchingRelationship);
142150
}
143151
}
144152
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
using System.Collections.Concurrent;
2+
using System.Reflection;
3+
using System.Xml.XPath;
4+
using JsonApiDotNetCore.Configuration;
5+
using JsonApiDotNetCore.Resources.Annotations;
6+
using Swashbuckle.AspNetCore.SwaggerGen;
7+
8+
namespace JsonApiDotNetCore.OpenApi.SwaggerComponents;
9+
10+
internal sealed class ResourceObjectDocumentationReader
11+
{
12+
private static readonly ConcurrentDictionary<string, XPathNavigator?> NavigatorsByAssemblyPath = new();
13+
14+
public string? GetDocumentationForType(ResourceType resourceType)
15+
{
16+
ArgumentGuard.NotNull(resourceType);
17+
18+
XPathNavigator? navigator = GetNavigator(resourceType.ClrType.Assembly);
19+
20+
if (navigator != null)
21+
{
22+
string typeMemberName = XmlCommentsNodeNameHelper.GetMemberNameForType(resourceType.ClrType);
23+
return GetSummary(navigator, typeMemberName);
24+
}
25+
26+
return null;
27+
}
28+
29+
public string? GetDocumentationForAttribute(AttrAttribute attribute)
30+
{
31+
ArgumentGuard.NotNull(attribute);
32+
33+
XPathNavigator? navigator = GetNavigator(attribute.Type.ClrType.Assembly);
34+
35+
if (navigator != null)
36+
{
37+
string propertyMemberName = XmlCommentsNodeNameHelper.GetMemberNameForFieldOrProperty(attribute.Property);
38+
return GetSummary(navigator, propertyMemberName);
39+
}
40+
41+
return null;
42+
}
43+
44+
public string? GetDocumentationForRelationship(RelationshipAttribute relationship)
45+
{
46+
ArgumentGuard.NotNull(relationship);
47+
48+
XPathNavigator? navigator = GetNavigator(relationship.Type.ClrType.Assembly);
49+
50+
if (navigator != null)
51+
{
52+
string propertyMemberName = XmlCommentsNodeNameHelper.GetMemberNameForFieldOrProperty(relationship.Property);
53+
return GetSummary(navigator, propertyMemberName);
54+
}
55+
56+
return null;
57+
}
58+
59+
private static XPathNavigator? GetNavigator(Assembly assembly)
60+
{
61+
string assemblyPath = assembly.Location;
62+
63+
if (!string.IsNullOrEmpty(assemblyPath))
64+
{
65+
return NavigatorsByAssemblyPath.GetOrAdd(assemblyPath, path =>
66+
{
67+
string documentationPath = Path.ChangeExtension(path, ".xml");
68+
69+
if (File.Exists(documentationPath))
70+
{
71+
var document = new XPathDocument(documentationPath);
72+
return document.CreateNavigator();
73+
}
74+
75+
return null;
76+
});
77+
}
78+
79+
return null;
80+
}
81+
82+
private string? GetSummary(XPathNavigator navigator, string memberName)
83+
{
84+
XPathNavigator? summaryNode = navigator.SelectSingleNode($"/doc/members/member[@name='{memberName}']/summary");
85+
return summaryNode != null ? XmlCommentsTextHelper.Humanize(summaryNode.InnerXml) : null;
86+
}
87+
}

src/JsonApiDotNetCore.OpenApi/SwaggerComponents/ResourceObjectSchemaGenerator.cs

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ internal sealed class ResourceObjectSchemaGenerator
2323
private readonly ISchemaRepositoryAccessor _schemaRepositoryAccessor;
2424
private readonly ResourceTypeSchemaGenerator _resourceTypeSchemaGenerator;
2525
private readonly Func<ResourceTypeInfo, ResourceFieldObjectSchemaBuilder> _resourceFieldObjectSchemaBuilderFactory;
26+
private readonly ResourceObjectDocumentationReader _resourceObjectDocumentationReader;
2627

2728
public ResourceObjectSchemaGenerator(SchemaGenerator defaultSchemaGenerator, IResourceGraph resourceGraph, IJsonApiOptions options,
2829
ISchemaRepositoryAccessor schemaRepositoryAccessor, ResourceFieldValidationMetadataProvider resourceFieldValidationMetadataProvider)
@@ -42,6 +43,8 @@ public ResourceObjectSchemaGenerator(SchemaGenerator defaultSchemaGenerator, IRe
4243

4344
_resourceFieldObjectSchemaBuilderFactory = resourceTypeInfo => new ResourceFieldObjectSchemaBuilder(resourceTypeInfo, schemaRepositoryAccessor,
4445
defaultSchemaGenerator, _resourceTypeSchemaGenerator, options.SerializerOptions.PropertyNamingPolicy, resourceFieldValidationMetadataProvider);
46+
47+
_resourceObjectDocumentationReader = new ResourceObjectDocumentationReader();
4548
}
4649

4750
public OpenApiSchema GenerateSchema(Type resourceObjectType)
@@ -55,7 +58,7 @@ public OpenApiSchema GenerateSchema(Type resourceObjectType)
5558

5659
RemoveResourceIdIfPostResourceObject(resourceTypeInfo, fullSchemaForResourceObject);
5760

58-
SetResourceType(fullSchemaForResourceObject, resourceTypeInfo.ResourceType.ClrType);
61+
SetResourceType(fullSchemaForResourceObject, resourceTypeInfo.ResourceType);
5962

6063
SetResourceAttributes(fullSchemaForResourceObject, fieldObjectBuilder);
6164

@@ -96,9 +99,11 @@ private void RemoveResourceIdIfPostResourceObject(ResourceTypeInfo resourceTypeI
9699
}
97100
}
98101

99-
private void SetResourceType(OpenApiSchema fullSchemaForResourceObject, Type resourceType)
102+
private void SetResourceType(OpenApiSchema fullSchemaForResourceObject, ResourceType resourceType)
100103
{
101-
fullSchemaForResourceObject.Properties[JsonApiPropertyName.Type] = _resourceTypeSchemaGenerator.Get(resourceType);
104+
fullSchemaForResourceObject.Properties[JsonApiPropertyName.Type] = _resourceTypeSchemaGenerator.Get(resourceType.ClrType);
105+
106+
fullSchemaForResourceObject.Description = _resourceObjectDocumentationReader.GetDocumentationForType(resourceType);
102107
}
103108

104109
private void SetResourceAttributes(OpenApiSchema fullSchemaForResourceObject, ResourceFieldObjectSchemaBuilder builder)

test/OpenApiTests/DocComments/DocCommentsTests.cs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ public DocCommentsTests(OpenApiTestContext<DocCommentsStartup<DocCommentsDbConte
1515
_testContext = testContext;
1616

1717
testContext.UseController<SkyscrapersController>();
18+
testContext.UseController<ElevatorsController>();
19+
testContext.UseController<SpacesController>();
1820
}
1921

2022
[Fact]
@@ -422,4 +424,50 @@ public async Task Endpoints_are_documented()
422424
});
423425
});
424426
}
427+
428+
[Fact]
429+
public async Task Resource_types_are_documented()
430+
{
431+
// Act
432+
JsonElement document = await _testContext.GetSwaggerDocumentAsync();
433+
434+
// Assert
435+
document.Should().ContainPath("components.schemas").With(schemasElement =>
436+
{
437+
schemasElement.Should().HaveProperty("elevatorDataInPatchRequest.description", "An elevator within a skyscraper.");
438+
schemasElement.Should().HaveProperty("elevatorDataInPostRequest.description", "An elevator within a skyscraper.");
439+
schemasElement.Should().HaveProperty("elevatorDataInResponse.description", "An elevator within a skyscraper.");
440+
441+
schemasElement.Should().HaveProperty("skyscraperDataInPatchRequest.description", "A tall, continuously habitable building having multiple floors.");
442+
schemasElement.Should().HaveProperty("skyscraperDataInPostRequest.description", "A tall, continuously habitable building having multiple floors.");
443+
schemasElement.Should().HaveProperty("skyscraperDataInResponse.description", "A tall, continuously habitable building having multiple floors.");
444+
445+
schemasElement.Should().HaveProperty("spaceDataInPatchRequest.description", "A space within a skyscraper, such as an office, hotel, residential space, or retail space.");
446+
schemasElement.Should().HaveProperty("spaceDataInPostRequest.description", "A space within a skyscraper, such as an office, hotel, residential space, or retail space.");
447+
schemasElement.Should().HaveProperty("spaceDataInResponse.description", "A space within a skyscraper, such as an office, hotel, residential space, or retail space.");
448+
});
449+
}
450+
451+
[Fact]
452+
public async Task Resource_attributes_are_documented()
453+
{
454+
// Act
455+
JsonElement document = await _testContext.GetSwaggerDocumentAsync();
456+
457+
// Assert
458+
document.Should().ContainPath("components.schemas").With(schemasElement =>
459+
{
460+
schemasElement.Should().HaveProperty("elevatorAttributesInPatchRequest.properties.floorCount.description", "The number of floors this elevator provides access to.");
461+
schemasElement.Should().HaveProperty("elevatorAttributesInPostRequest.properties.floorCount.description", "The number of floors this elevator provides access to.");
462+
schemasElement.Should().HaveProperty("elevatorAttributesInResponse.properties.floorCount.description", "The number of floors this elevator provides access to.");
463+
464+
schemasElement.Should().HaveProperty("skyscraperAttributesInPatchRequest.properties.heightInMeters.description", "The height of this building, in meters.");
465+
schemasElement.Should().HaveProperty("skyscraperAttributesInPostRequest.properties.heightInMeters.description", "The height of this building, in meters.");
466+
schemasElement.Should().HaveProperty("skyscraperAttributesInResponse.properties.heightInMeters.description", "The height of this building, in meters.");
467+
468+
schemasElement.Should().HaveProperty("spaceAttributesInPatchRequest.properties.floorNumber.description", "The floor number on which this space resides.");
469+
schemasElement.Should().HaveProperty("spaceAttributesInPostRequest.properties.floorNumber.description", "The floor number on which this space resides.");
470+
schemasElement.Should().HaveProperty("spaceAttributesInResponse.properties.floorNumber.description", "The floor number on which this space resides.");
471+
});
472+
}
425473
}

0 commit comments

Comments
 (0)