Skip to content

Commit

Permalink
Infer Swagger route param type from route constraint when there's no …
Browse files Browse the repository at this point in the history
…request DTO
  • Loading branch information
KateyBee committed Apr 18, 2024
1 parent 63bca00 commit 2155df2
Show file tree
Hide file tree
Showing 8 changed files with 138 additions and 82 deletions.
6 changes: 5 additions & 1 deletion FastEndpoints.sln.DotSettings
Expand Up @@ -157,7 +157,11 @@
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/PredefinedNamingRules/=Other/@EntryIndexedValue">&lt;Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /&gt;</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/PredefinedNamingRules/=Parameters/@EntryIndexedValue">&lt;Policy Inspect="True" Prefix="" Suffix="" Style="aaBb"&gt;&lt;ExtraRule Prefix="" Suffix="" Style="AaBb" /&gt;&lt;/Policy&gt;</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/PredefinedNamingRules/=PrivateStaticReadonly/@EntryIndexedValue">&lt;Policy Inspect="True" Prefix="_" Suffix="" Style="aaBb" /&gt;</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/UserRules/=15b5b1f1_002D457c_002D4ca6_002Db278_002D5615aedc07d3/@EntryIndexedValue">&lt;Policy&gt;&lt;Descriptor Staticness="Static" AccessRightKinds="Private" Description="Static readonly fields (private)"&gt;&lt;ElementKinds&gt;&lt;Kind Name="READONLY_FIELD" /&gt;&lt;/ElementKinds&gt;&lt;/Descriptor&gt;&lt;Policy Inspect="True" Prefix="_" Suffix="" Style="aaBb" /&gt;&lt;/Policy&gt;</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/UserRules/=8284009d_002De743_002D4d89_002D9402_002Da5bf9a89b657/@EntryIndexedValue">&lt;Policy&gt;&lt;Descriptor Staticness="Any" AccessRightKinds="Any" Description="Methods"&gt;&lt;ElementKinds&gt;&lt;Kind Name="METHOD" /&gt;&lt;/ElementKinds&gt;&lt;/Descriptor&gt;&lt;Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /&gt;&lt;/Policy&gt;</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/UserRules/=8a85b61a_002D1024_002D4f87_002Db9ef_002D1fdae19930a1/@EntryIndexedValue">&lt;Policy&gt;&lt;Descriptor Staticness="Any" AccessRightKinds="Any" Description="Parameters"&gt;&lt;ElementKinds&gt;&lt;Kind Name="PARAMETER" /&gt;&lt;/ElementKinds&gt;&lt;/Descriptor&gt;&lt;Policy Inspect="True" Prefix="" Suffix="" Style="aaBb"&gt;&lt;ExtraRule Prefix="" Suffix="" Style="AaBb" /&gt;&lt;/Policy&gt;&lt;/Policy&gt;</s:String>
<s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EPsi_002ECSharp_002ECodeStyle_002ECSharpKeepExistingMigration/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EPsi_002ECSharp_002ECodeStyle_002ECSharpPlaceEmbeddedOnSameLineMigration/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EPsi_002ECSharp_002ECodeStyle_002ECSharpUseContinuousIndentInsideBracesMigration/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EPsi_002ECSharp_002ECodeStyle_002ESettingsUpgrade_002EMigrateBlankLinesAroundFieldToBlankLinesAroundProperty/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>
<s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EPsi_002ECSharp_002ECodeStyle_002ESettingsUpgrade_002EMigrateBlankLinesAroundFieldToBlankLinesAroundProperty/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EPsi_002ECSharp_002ECodeStyle_002ESettingsUpgrade_002EPredefinedNamingRulesToUserRulesUpgrade/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>
2 changes: 1 addition & 1 deletion Src/Directory.Build.props
@@ -1,7 +1,7 @@
<Project>
<PropertyGroup>

<Version>5.24.0.1-beta</Version>
<Version>5.24.0.2-beta</Version>

<TargetFrameworks>net6.0;net7.0;net8.0</TargetFrameworks>
<LangVersion>latest</LangVersion>
Expand Down
38 changes: 36 additions & 2 deletions Src/Library/changelog.md
Expand Up @@ -29,9 +29,43 @@ Summary(

</details>

## Improvements 🚀
<details><summary>Automatic type inference for route params from route constraints for Swagger</summary>

## Fixes 🪲
Given route templates such as the following that has type constraints for route params, it was previously only possible to correctly infer the type of the parameter (for Swagger spec generation) if the parameters are being bound to a request DTO and that DTO has a matching property. The following will now work out of the box and the generated Swagger spec will have the respective parameter type/format.

```csharp
sealed class MyEndpoint : EndpointWithoutRequest
{
public override void Configure()
{
Get("test/{id:int}/{token:guid}/{date:datetime}");
AllowAnonymous();
}

public override async Task HandleAsync(CancellationToken c)
{
var id = Route<int>("id");
var token = Route<Guid>("token");
var date = Route<DateTime>("date");

await SendAsync(new { id, token, date });
}
}
```

You can register your own route constraint types or even override the default ones like below by updating the Swagger route constraint map:

```csharp
FastEndpoints.Swagger.GlobalConfig.RouteConstraintMap["nonzero"] = typeof(long);
FastEndpoints.Swagger.GlobalConfig.RouteConstraintMap["guid"] = typeof(Guid);
FastEndpoints.Swagger.GlobalConfig.RouteConstraintMap["date"] = typeof(DateTime);
```

</details>

[//]: # (## Improvements 🚀)

[//]: # (## Fixes 🪲)

## Breaking Changes ⚠️

Expand Down
19 changes: 19 additions & 0 deletions Src/Swagger/GlobalConfig.cs
Expand Up @@ -26,4 +26,23 @@ public static class GlobalConfig
/// allows the use of empty request dtos
/// </summary>
public static bool AllowEmptyRequestDtos => Conf.EpOpts.AllowEmptyRequestDtos;

/// <summary>
/// this route constraint type map will be used to determine the type for a route parameter if there's no matching property on the request dto.
/// the dictionary key is the name of the constraint and the value is the corresponding <see cref="System.Type" />
/// </summary>
public static Dictionary<string, Type> RouteConstraintMap { get; } = new(StringComparer.OrdinalIgnoreCase)
{
{ "int", typeof(int) },
{ "bool", typeof(bool) },
{ "datetime", typeof(DateTime) },
{ "decimal", typeof(decimal) },
{ "double", typeof(double) },
{ "float", typeof(float) },
{ "guid", typeof(Guid) },
{ "long", typeof(long) },
{ "min", typeof(long) },
{ "max", typeof(long) },
{ "range", typeof(long) }
};
}
146 changes: 71 additions & 75 deletions Src/Swagger/OperationProcessor.cs
Expand Up @@ -296,6 +296,8 @@ public bool Process(OperationProcessorContext ctx)
}
}

var paramCtx = new ParamCreationContext(ctx, docOpts, serializer, reqParamDescriptions, apiDescription.RelativePath!);

//add a path param for each route param such as /{xxx}/{yyy}/{zzz}
var reqParams = _routeParamsRegex
.Matches(opPath)
Expand All @@ -317,15 +319,7 @@ public bool Process(OperationProcessorContext ctx)
return true;
});
return CreateParam(
ctx: ctx,
prop: pInfo,
paramName: m.Value,
isRequired: true,
kind: OpenApiParameterKind.Path,
descriptions: reqParamDescriptions,
docOpts: docOpts,
serializer: serializer);
return CreateParam(paramCtx, OpenApiParameterKind.Path, pInfo, m.Value, true);
})
.ToList();

Expand All @@ -339,15 +333,7 @@ public bool Process(OperationProcessorContext ctx)
{
RemovePropFromRequestBodyContent(p.Name, reqContent, propsToRemoveFromExample, docOpts);
return CreateParam(
ctx: ctx,
prop: p,
paramName: null,
isRequired: null,
kind: OpenApiParameterKind.Query,
descriptions: reqParamDescriptions,
docOpts: docOpts,
serializer: serializer);
return CreateParam(paramCtx, OpenApiParameterKind.Query, p);
})
.ToList();

Expand Down Expand Up @@ -375,16 +361,7 @@ public bool Process(OperationProcessorContext ctx)
continue;
}

reqParams.Add(
CreateParam(
ctx: ctx,
prop: p,
paramName: pName,
isRequired: hAttrib.IsRequired,
kind: OpenApiParameterKind.Header,
descriptions: reqParamDescriptions,
docOpts: docOpts,
serializer: serializer));
reqParams.Add(CreateParam(paramCtx, OpenApiParameterKind.Header, p, pName, hAttrib.IsRequired));

//remove corresponding json body field if it's required. allow binding only from header.
if (hAttrib.IsRequired || hAttrib.RemoveFromSchema)
Expand Down Expand Up @@ -415,16 +392,7 @@ public bool Process(OperationProcessorContext ctx)

RemovePropFromRequestBodyContent(p.Name, reqContent, propsToRemoveFromExample, docOpts);
reqDtoProps.Remove(p);
reqParams.Add(
CreateParam(
ctx: ctx,
prop: p,
paramName: null,
isRequired: null,
kind: OpenApiParameterKind.FormData,
descriptions: reqParamDescriptions,
docOpts: docOpts,
serializer: serializer));
reqParams.Add(CreateParam(paramCtx, OpenApiParameterKind.FormData, p));
}
}

Expand Down Expand Up @@ -473,16 +441,7 @@ public bool Process(OperationProcessorContext ctx)
foreach (var body in op.Parameters.Where(x => x.Kind == OpenApiParameterKind.Body).ToArray())
{
op.Parameters.Remove(body);
op.Parameters.Add(
CreateParam(
ctx: ctx,
prop: fromBodyProp,
paramName: fromBodyProp.Name,
isRequired: true,
kind: OpenApiParameterKind.Body,
descriptions: reqParamDescriptions,
docOpts: docOpts,
serializer: serializer));
op.Parameters.Add(CreateParam(paramCtx, OpenApiParameterKind.Body, fromBodyProp, fromBodyProp.Name, true));
}
}

Expand Down Expand Up @@ -639,58 +598,51 @@ string StripSymbols(string val)
=> stripSymbols ? Regex.Replace(val, "[^a-zA-Z0-9]", "") : val;
}

static OpenApiParameter CreateParam(OperationProcessorContext ctx,
PropertyInfo? prop,
string? paramName,
bool? isRequired,
OpenApiParameterKind kind,
Dictionary<string, string>? descriptions,
DocumentOptions docOpts,
JsonSerializer serializer)
static OpenApiParameter CreateParam(ParamCreationContext ctx, OpenApiParameterKind Kind, PropertyInfo? Prop = null, string? ParamName = null, bool? IsRequired = null)
{
paramName = paramName?.ApplyPropNamingPolicy(docOpts) ??
prop?.GetCustomAttribute<BindFromAttribute>()?.Name ?? //don't apply naming policy to attribute value
prop?.Name.ApplyPropNamingPolicy(docOpts) ?? throw new InvalidOperationException("param name is required!");
ParamName = ParamName?.ApplyPropNamingPolicy(ctx.DocOpts) ??
Prop?.GetCustomAttribute<BindFromAttribute>()?.Name ?? //don't apply naming policy to attribute value
Prop?.Name.ApplyPropNamingPolicy(ctx.DocOpts) ?? throw new InvalidOperationException("param name is required!");

Type propType;

if (prop?.PropertyType is not null)
if (Prop?.PropertyType is not null) //dto has matching property
{
propType = prop.PropertyType;
propType = Prop.PropertyType;
if (propType.Name.EndsWith("HeaderValue"))
propType = Types.String;
}
else
propType = Types.String;
else //dto doesn't have matching prop, get it from route constraint map
propType = ctx.TypeForRouteParam(ParamName);

var prm = ctx.DocumentGenerator.CreatePrimitiveParameter(
paramName,
descriptions?.GetValueOrDefault(prop?.Name ?? paramName),
var prm = ctx.OpCtx.DocumentGenerator.CreatePrimitiveParameter(
ParamName,
ctx.Descriptions?.GetValueOrDefault(Prop?.Name ?? ParamName),
propType.ToContextualType());

prm.Kind = kind;
prm.Kind = Kind;

var defaultValFromCtorArg = prop?.GetParentCtorDefaultValue();
var defaultValFromCtorArg = Prop?.GetParentCtorDefaultValue();
bool? hasDefaultValFromCtorArg = null;
if (defaultValFromCtorArg is not null)
hasDefaultValFromCtorArg = true;

var isNullable = prop?.IsNullable();
var isNullable = Prop?.IsNullable();

prm.IsRequired = isRequired ??
prm.IsRequired = IsRequired ??
!hasDefaultValFromCtorArg ??
!(isNullable ?? true);

prm.Schema.IsNullableRaw = prm.IsRequired ? null : isNullable;

if (ctx.Settings.SchemaSettings.SchemaType == SchemaType.Swagger2)
prm.Default = prop?.GetCustomAttribute<DefaultValueAttribute>()?.Value ?? defaultValFromCtorArg;
if (ctx.OpCtx.Settings.SchemaSettings.SchemaType == SchemaType.Swagger2)
prm.Default = Prop?.GetCustomAttribute<DefaultValueAttribute>()?.Value ?? defaultValFromCtorArg;
else
prm.Schema.Default = prop?.GetCustomAttribute<DefaultValueAttribute>()?.Value ?? defaultValFromCtorArg;
prm.Schema.Default = Prop?.GetCustomAttribute<DefaultValueAttribute>()?.Value ?? defaultValFromCtorArg;

if (ctx.Settings.SchemaSettings.GenerateExamples)
if (ctx.OpCtx.Settings.SchemaSettings.GenerateExamples)
{
prm.Example = prop?.GetExampleJToken(serializer);
prm.Example = Prop?.GetExampleJToken(ctx.Serializer);

if (prm.Example is null && prm.Default is null && prm.Schema?.Default is null && prm.IsRequired)
{
Expand All @@ -703,4 +655,48 @@ string StripSymbols(string val)

return prm;
}

readonly struct ParamCreationContext
{
public OperationProcessorContext OpCtx { get; }
public DocumentOptions DocOpts { get; }
public JsonSerializer Serializer { get; }
public Dictionary<string, string>? Descriptions { get; }

readonly Dictionary<string, Type> _paramMap;

public ParamCreationContext(OperationProcessorContext opCtx,
DocumentOptions docOpts,
JsonSerializer serializer,
Dictionary<string, string>? descriptions,
string operationPath)
{
OpCtx = opCtx;
DocOpts = docOpts;
Serializer = serializer;
Descriptions = descriptions;
_paramMap = new(
operationPath.Split('/')
.Where(s => s.Contains('{') && s.Contains(':') && s.Contains('}'))
.Select(
s =>
{
var parts = s.Split(':');
var left = parts[0];
var right = parts[1];
left = left[(left.IndexOf('{') + 1)..].Trim();
right = right[..right.IndexOfAny(['(', '}'])].Trim();
GlobalConfig.RouteConstraintMap.TryGetValue(right, out var tParam);
return new KeyValuePair<string, Type>(left, tParam ?? Types.String);
}));
}

public Type TypeForRouteParam(string paramName)
=> _paramMap.TryGetValue(paramName, out var tParam)
? tParam
: Types.String;
}
}
Expand Up @@ -3754,7 +3754,8 @@
"in": "path",
"required": true,
"schema": {
"type": "string"
"type": "integer",
"format": "int32"
}
},
{
Expand Down
3 changes: 2 additions & 1 deletion Tests/IntegrationTests/FastEndpoints.Swagger/release-1.json
Expand Up @@ -3846,7 +3846,8 @@
"in": "path",
"required": true,
"schema": {
"type": "string"
"type": "integer",
"format": "int32"
}
},
{
Expand Down
3 changes: 2 additions & 1 deletion Tests/IntegrationTests/FastEndpoints.Swagger/release-2.json
Expand Up @@ -3970,7 +3970,8 @@
"in": "path",
"required": true,
"schema": {
"type": "string"
"type": "integer",
"format": "int32"
}
},
{
Expand Down

0 comments on commit 2155df2

Please sign in to comment.