Skip to content

Commit

Permalink
Improved generated TypeScript from @hey-api, for response DTOs, in th…
Browse files Browse the repository at this point in the history
…e generated OpenAPI.
  • Loading branch information
jezzsantos committed Dec 2, 2024
1 parent 1159bfc commit e8eb6a4
Show file tree
Hide file tree
Showing 13 changed files with 5,217 additions and 1,010 deletions.
7 changes: 6 additions & 1 deletion src/Infrastructure.Web.Api.IntegrationTests/ApiDocsSpec.cs
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,10 @@ public async Task WhenFetchOpenApi_ThenHasNullableResponseFields()
operation.OperationId.Should().Be("OpenApiGetTestingOnly");

var responseType = openApi.Components.Schemas[nameof(OpenApiTestingOnlyResponse)];
responseType.Required.Count.Should().Be(5);
responseType.Required.Should().ContainInOrder("anAnnotatedRequiredField", "anInitializedField",
"aRequiredField",
"aValueTypeField", "message");
responseType.Properties["anAnnotatedRequiredField"].Nullable.Should().BeFalse();
responseType.Properties["anInitializedField"].Nullable.Should().BeFalse();
responseType.Properties["aNullableField"].Nullable.Should().BeTrue();
Expand Down Expand Up @@ -300,7 +304,8 @@ public async Task WhenFetchOpenApi_ThenResponseHasGeneralErrorResponses()

var operation = openApi!.Paths["/testingonly/openapi/{Id}"].Operations[OperationType.Post];
operation.Responses.Count.Should().Be(11);
VerifyGeneralErrorResponses(operation.Responses, "a custom conflict response");
VerifyGeneralErrorResponses(operation.Responses,
"a custom conflict response which spills over to the next line");
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,17 @@ public void Apply(OpenApiSchema schema, SchemaFilterContext context)
{
var member = context.MemberInfo;
var declaringType = member.DeclaringType;
if (declaringType.IsRequestOrResponseType())
if (declaringType.IsRequestType())
{
// dealing with each property of a request and responses type
// dealing with each property of a request type
schema.SetPropertyDescription(member);
if (declaringType.IsResponseType())
{
schema.SetPropertyNullable(member);
}
}

if (declaringType.IsResponseType())
{
// dealing with each property of a responses type
schema.SetPropertyDescription(member);
schema.SetPropertyNullable(member);
}

return;
Expand All @@ -39,11 +42,11 @@ public void Apply(OpenApiSchema schema, SchemaFilterContext context)
}

// dealing with any other schemas in general
var dtoType = context.Type;
if (context.Type.IsRequestOrResponseType())
{
var requestType = context.Type;
schema.CollateRequiredProperties(requestType);
schema.RemoveRouteTemplateFields(requestType);
schema.CollateRequiredProperties(dtoType);
schema.RemoveRouteTemplateFields(dtoType);
}

if (context.Type.IsEnum)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,23 +15,58 @@ namespace Infrastructure.Web.Hosting.Common.Documentation;
internal static class DataAnnotationsSchemaFilterExtensions
{
/// <summary>
/// Collates the required properties of the request type, into the schema
/// Collates the required properties of the request or response type, into the schema
/// </summary>
public static void CollateRequiredProperties(this OpenApiSchema schema, Type requestType)
public static void CollateRequiredProperties(this OpenApiSchema schema, Type type)
{
var properties = requestType.GetProperties();
var required = schema.Required ?? new HashSet<string>();
var properties = type.GetProperties();
foreach (var property in properties)
{
// we have to add all required properties to the request collection
if (property.IsPropertyRequired())
if (IsRequestType(type))
{
var name = property.Name.ToCamelCase();
var required = schema.Required ?? new HashSet<string>();
// ReSharper disable once PossibleUnintendedLinearSearchInSet
if (!required.Contains(name, StringComparer.OrdinalIgnoreCase))
if (property.IsRequestPropertyRequired())
{
required.Add(name);
AddToRequired(property);
}
else
{
RemoveFromRequired(property);
}
}

if (IsResponseType(type))
{
if (property.IsResponsePropertyRequired())
{
AddToRequired(property);
}
else
{
RemoveFromRequired(property);
}
}
}

return;

void AddToRequired(PropertyInfo property)
{
var name = property.Name.ToCamelCase();
// ReSharper disable once PossibleUnintendedLinearSearchInSet
if (!required.Contains(name, StringComparer.OrdinalIgnoreCase))
{
required.Add(name);
}
}

void RemoveFromRequired(PropertyInfo property)
{
var name = property.Name.ToCamelCase();
// ReSharper disable once PossibleUnintendedLinearSearchInSet
if (required.Contains(name, StringComparer.OrdinalIgnoreCase))
{
required.Remove(name);
}
}
}
Expand All @@ -49,7 +84,16 @@ public static bool IsPropertyInRoute(this PropertyInfo property)
return IsInRoute(routeAttribute, name);
}

public static bool IsPropertyRequired(this PropertyInfo property)
/// <summary>
/// Determines if the type is a request or response type, which are the only ones that are annotatable
/// with <see cref="System.ComponentModel.DataAnnotations" /> attributes
/// </summary>
public static bool IsRequestOrResponseType(this Type? parent)
{
return IsRequestType(parent) || IsResponseType(parent);
}

public static bool IsRequestPropertyRequired(this PropertyInfo property)
{
if (property.HasAttribute<RequiredAttribute>())
{
Expand All @@ -60,14 +104,12 @@ public static bool IsPropertyRequired(this PropertyInfo property)
}

/// <summary>
/// Determines if the type is a request or response type, which are the only ones that are annotatable
/// Determines if the type is a request type, which are the only ones that are annotatable
/// with <see cref="System.ComponentModel.DataAnnotations" /> attributes
/// </summary>
public static bool IsRequestOrResponseType(this Type? parent)
public static bool IsRequestType(this Type? parent)
{
return parent.Exists()
&& (parent.IsAssignableTo(typeof(IWebRequest))
|| IsResponseType(parent));
return parent.Exists() && parent.IsAssignableTo(typeof(IWebRequest));
}

/// <summary>
Expand All @@ -83,9 +125,9 @@ public static bool IsResponseType(this Type? parent)
/// Removes any properties from the schema that are used in the path of the route template,
/// which will be passed as route parameters
/// </summary>
public static void RemoveRouteTemplateFields(this OpenApiSchema schema, Type requestType)
public static void RemoveRouteTemplateFields(this OpenApiSchema schema, Type type)
{
var routeAttribute = requestType.GetCustomAttribute<RouteAttribute>();
var routeAttribute = type.GetCustomAttribute<RouteAttribute>();
if (routeAttribute.NotExists())
{
return;
Expand All @@ -102,7 +144,7 @@ public static void RemoveRouteTemplateFields(this OpenApiSchema schema, Type req
return;
}

var placeholders = requestType.GetRouteTemplatePlaceholders();
var placeholders = type.GetRouteTemplatePlaceholders();
foreach (var placeholder in placeholders)
{
var property = schema.Properties.FirstOrDefault(prop => prop.Key.EqualsIgnoreCase(placeholder.Key));
Expand Down Expand Up @@ -157,7 +199,7 @@ public static void SetPropertyDescription(this OpenApiSchema schema, MemberInfo
{
return;
}

var descriptionAttribute = property.GetCustomAttribute<DescriptionAttribute>();
if (descriptionAttribute.Exists())
{
Expand All @@ -179,17 +221,37 @@ public static void SetPropertyNullable(this OpenApiSchema schema, MemberInfo pro
}

var propertyInfo = (property as PropertyInfo)!;
var isNullable = propertyInfo.IsResponsePropertyNullable();

schema.Nullable = isNullable;
}

public static void SetRequired(this OpenApiParameter parameter, ParameterInfo parameterInfo)
{
if (parameter.In == ParameterLocation.Path
|| parameterInfo.GetCustomAttribute<RequiredAttribute>().Exists())
{
parameter.Required = true;
}
}

private static bool IsResponsePropertyRequired(this PropertyInfo propertyInfo)
{
return !propertyInfo.IsResponsePropertyNullable();
}

private static bool IsResponsePropertyNullable(this PropertyInfo propertyInfo)
{
var isNullable = false;

// Check for the [Required] DataAnnotation attribute
if (property.GetCustomAttribute<RequiredAttribute>().Exists())
if (propertyInfo.GetCustomAttribute<RequiredAttribute>().Exists())
{
isNullable = false;
}

// Check for the 'required' C# keyword
if (property.GetCustomAttribute<RequiredMemberAttribute>().Exists())
if (propertyInfo.GetCustomAttribute<RequiredMemberAttribute>().Exists())
{
isNullable = false;
}
Expand All @@ -200,21 +262,12 @@ public static void SetPropertyNullable(this OpenApiSchema schema, MemberInfo pro
}

// Check for the ? nullable annotation
if (property.GetCustomAttribute<NullableAttribute>().Exists())
if (propertyInfo.GetCustomAttribute<NullableAttribute>().Exists())
{
isNullable = true;
}

schema.Nullable = isNullable;
}

public static void SetRequired(this OpenApiParameter parameter, ParameterInfo parameterInfo)
{
if (parameter.In == ParameterLocation.Path
|| parameterInfo.GetCustomAttribute<RequiredAttribute>().Exists())
{
parameter.Required = true;
}
return isNullable;
}

private static bool IsInRoute(RouteAttribute routeAttribute, string name)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ public void Apply(OpenApiOperation operation, OperationFilterContext context)
Type = ConvertToSchemaType(context, property)
});

if (property.IsPropertyRequired())
if (property.IsRequestPropertyRequired())
{
requiredParts.Add(name);
}
Expand Down Expand Up @@ -104,7 +104,7 @@ public void Apply(OpenApiOperation operation, OperationFilterContext context)
Type = ConvertToSchemaType(context, property)
});

if (property.IsPropertyRequired())
if (property.IsRequestPropertyRequired())
{
requiredParts.Add(name);
}
Expand Down
12 changes: 6 additions & 6 deletions src/WebsiteHost/ClientApp/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading

0 comments on commit e8eb6a4

Please sign in to comment.