400 lines
No EOL
14 KiB
C#
400 lines
No EOL
14 KiB
C#
using System.Diagnostics.CodeAnalysis;
|
|
using System.Reflection;
|
|
using Iceshrimp.Backend.Controllers.Federation.Attributes;
|
|
using Iceshrimp.Backend.Controllers.Mastodon.Attributes;
|
|
using Iceshrimp.Backend.Controllers.Mastodon.Schemas;
|
|
using Iceshrimp.Backend.Controllers.Shared.Attributes;
|
|
using Iceshrimp.Backend.Core.Middleware;
|
|
using Iceshrimp.Shared.Schemas.Web;
|
|
using Microsoft.AspNetCore.Mvc.ApiExplorer;
|
|
using Microsoft.AspNetCore.WebUtilities;
|
|
using Microsoft.OpenApi.Any;
|
|
using Microsoft.OpenApi.Models;
|
|
using Swashbuckle.AspNetCore.SwaggerGen;
|
|
|
|
namespace Iceshrimp.Backend.Core.Extensions;
|
|
|
|
public static class SwaggerGenOptionsExtensions
|
|
{
|
|
public static void AddFilters(this SwaggerGenOptions options)
|
|
{
|
|
options.SchemaFilter<RequireNonNullablePropertiesSchemaFilter>();
|
|
options.SchemaFilter<SwaggerBodyExampleSchemaFilter>();
|
|
options.SupportNonNullableReferenceTypes(); // Sets Nullable flags appropriately.
|
|
options.UseAllOfToExtendReferenceSchemas(); // Allows $ref enums to be nullable
|
|
options.UseAllOfForInheritance(); // Allows $ref objects to be nullable
|
|
options.OperationFilter<AuthorizeCheckOperationDocumentFilter>();
|
|
options.OperationFilter<HybridRequestOperationFilter>();
|
|
options.OperationFilter<PossibleErrorsOperationFilter>();
|
|
options.OperationFilter<PossibleResultsOperationFilter>();
|
|
options.DocumentFilter<AuthorizeCheckOperationDocumentFilter>();
|
|
options.DocInclusionPredicate(DocInclusionPredicate);
|
|
}
|
|
|
|
private static bool DocInclusionPredicate(string docName, ApiDescription apiDesc)
|
|
{
|
|
if (!apiDesc.TryGetMethodInfo(out var methodInfo)) return false;
|
|
if (methodInfo.DeclaringType is null) return false;
|
|
|
|
var isMastodonController = methodInfo.DeclaringType.GetCustomAttributes(true)
|
|
.OfType<MastodonApiControllerAttribute>()
|
|
.Any();
|
|
|
|
var isFederationController = methodInfo.DeclaringType.GetCustomAttributes(true)
|
|
.OfType<FederationApiControllerAttribute>()
|
|
.Any();
|
|
|
|
return docName switch
|
|
{
|
|
"mastodon" when isMastodonController => true,
|
|
"federation" when isFederationController => true,
|
|
"iceshrimp" when !isMastodonController && !isFederationController => true,
|
|
_ => false
|
|
};
|
|
}
|
|
|
|
[SuppressMessage("ReSharper", "ClassNeverInstantiated.Local",
|
|
Justification = "SwaggerGenOptions.SchemaFilter<T> instantiates this class at runtime")]
|
|
private class RequireNonNullablePropertiesSchemaFilter : ISchemaFilter
|
|
{
|
|
public void Apply(OpenApiSchema model, SchemaFilterContext context)
|
|
{
|
|
var additionalRequiredProps = model.Properties
|
|
.Where(x => !x.Value.Nullable && !model.Required.Contains(x.Key))
|
|
.Select(x => x.Key);
|
|
foreach (var propKey in additionalRequiredProps)
|
|
{
|
|
model.Required.Add(propKey);
|
|
}
|
|
}
|
|
}
|
|
|
|
[SuppressMessage("ReSharper", "ClassNeverInstantiated.Local",
|
|
Justification = "SwaggerGenOptions.SchemaFilter<T> instantiates this class at runtime")]
|
|
private class SwaggerBodyExampleSchemaFilter : ISchemaFilter
|
|
{
|
|
public void Apply(OpenApiSchema schema, SchemaFilterContext context)
|
|
{
|
|
var att = context.ParameterInfo?.GetCustomAttribute<SwaggerBodyExampleAttribute>();
|
|
if (att != null)
|
|
schema.Example = new OpenApiString(att.Value);
|
|
}
|
|
}
|
|
|
|
[SuppressMessage("ReSharper", "ClassNeverInstantiated.Local",
|
|
Justification = "SwaggerGenOptions.OperationFilter<T> instantiates this class at runtime")]
|
|
private class AuthorizeCheckOperationDocumentFilter : IOperationFilter, IDocumentFilter
|
|
{
|
|
private const string Web401 =
|
|
"""
|
|
{
|
|
"statusCode": 401,
|
|
"error": "Unauthorized",
|
|
"message": "This method requires an authenticated user"
|
|
}
|
|
""";
|
|
|
|
private const string Web403 =
|
|
"""
|
|
{
|
|
"statusCode": 403,
|
|
"error": "Forbidden",
|
|
"message": "This action is outside the authorized scopes"
|
|
}
|
|
""";
|
|
|
|
private const string Masto401 =
|
|
"""
|
|
{
|
|
"error": "This method requires an authenticated user"
|
|
}
|
|
""";
|
|
|
|
private const string Masto403 =
|
|
"""
|
|
{
|
|
"message": "This action is outside the authorized scopes"
|
|
}
|
|
""";
|
|
|
|
private static readonly OpenApiString MastoExample401 = new(Masto401);
|
|
private static readonly OpenApiString MastoExample403 = new(Masto403);
|
|
private static readonly OpenApiString WebExample401 = new(Web401);
|
|
private static readonly OpenApiString WebExample403 = new(Web403);
|
|
|
|
private static readonly OpenApiReference Ref401 =
|
|
new() { Type = ReferenceType.Response, Id = "error-401" };
|
|
|
|
private static readonly OpenApiReference Ref403 =
|
|
new() { Type = ReferenceType.Response, Id = "error-403" };
|
|
|
|
private static readonly OpenApiResponse MastoRes401 = new()
|
|
{
|
|
Reference = Ref401,
|
|
Description = "Unauthorized",
|
|
Content = { ["application/json"] = new OpenApiMediaType { Example = MastoExample401 } }
|
|
};
|
|
|
|
private static readonly OpenApiResponse MastoRes403 = new()
|
|
{
|
|
Reference = Ref403,
|
|
Description = "Forbidden",
|
|
Content = { ["application/json"] = new OpenApiMediaType { Example = MastoExample403 } }
|
|
};
|
|
|
|
private static readonly OpenApiResponse WebRes401 = new()
|
|
{
|
|
Reference = Ref401,
|
|
Description = "Unauthorized",
|
|
Content = { ["application/json"] = new OpenApiMediaType { Example = WebExample401 } }
|
|
};
|
|
|
|
private static readonly OpenApiResponse WebRes403 = new()
|
|
{
|
|
Reference = Ref403,
|
|
Description = "Forbidden",
|
|
Content = { ["application/json"] = new OpenApiMediaType { Example = WebExample403 } }
|
|
};
|
|
|
|
public void Apply(OpenApiOperation operation, OperationFilterContext context)
|
|
{
|
|
if (context.MethodInfo.DeclaringType is null)
|
|
return;
|
|
|
|
var authenticateAttribute = context.MethodInfo.GetCustomAttributes(true)
|
|
.OfType<AuthenticateAttribute>()
|
|
.FirstOrDefault() ??
|
|
context.MethodInfo.DeclaringType.GetCustomAttributes(true)
|
|
.OfType<AuthenticateAttribute>()
|
|
.FirstOrDefault();
|
|
|
|
if (authenticateAttribute == null) return;
|
|
|
|
var isMastodonController = context.MethodInfo.DeclaringType.GetCustomAttributes(true)
|
|
.OfType<MastodonApiControllerAttribute>()
|
|
.Any();
|
|
|
|
var authorizeAttribute = context.MethodInfo.GetCustomAttributes(true)
|
|
.OfType<AuthorizeAttribute>()
|
|
.FirstOrDefault() ??
|
|
context.MethodInfo.DeclaringType.GetCustomAttributes(true)
|
|
.OfType<AuthorizeAttribute>()
|
|
.FirstOrDefault();
|
|
|
|
var schema = new OpenApiSecurityScheme
|
|
{
|
|
Reference = new OpenApiReference
|
|
{
|
|
Type = ReferenceType.SecurityScheme, Id = isMastodonController ? "mastodon" : "iceshrimp"
|
|
}
|
|
};
|
|
|
|
operation.Security = new List<OpenApiSecurityRequirement> { new() { [schema] = Array.Empty<string>() } };
|
|
|
|
if (authorizeAttribute == null) return;
|
|
|
|
operation.Responses.Remove("401");
|
|
operation.Responses.Add("401", new OpenApiResponse { Reference = Ref401 });
|
|
|
|
if (authorizeAttribute is { AdminRole: false, ModeratorRole: false, Scopes.Length: 0 } &&
|
|
authenticateAttribute is { AdminRole: false, ModeratorRole: false, Scopes.Length: 0 })
|
|
return;
|
|
|
|
operation.Responses.Remove("403");
|
|
operation.Responses.Add("403", new OpenApiResponse { Reference = Ref403 });
|
|
}
|
|
|
|
public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context)
|
|
{
|
|
if (swaggerDoc.Info.Title == "Mastodon")
|
|
{
|
|
swaggerDoc.Components.Responses.Add(Ref401.Id, MastoRes401);
|
|
swaggerDoc.Components.Responses.Add(Ref403.Id, MastoRes403);
|
|
}
|
|
else
|
|
{
|
|
swaggerDoc.Components.Responses.Add(Ref401.Id, WebRes401);
|
|
swaggerDoc.Components.Responses.Add(Ref403.Id, WebRes403);
|
|
}
|
|
}
|
|
}
|
|
|
|
[SuppressMessage("ReSharper", "ClassNeverInstantiated.Local",
|
|
Justification = "SwaggerGenOptions.OperationFilter<T> instantiates this class at runtime")]
|
|
private class PossibleErrorsOperationFilter : IOperationFilter
|
|
{
|
|
public void Apply(OpenApiOperation operation, OperationFilterContext context)
|
|
{
|
|
if (context.MethodInfo.DeclaringType is null)
|
|
return;
|
|
|
|
var attribute = context.MethodInfo.GetCustomAttributes(true)
|
|
.OfType<ProducesErrorsAttribute>()
|
|
.FirstOrDefault() ??
|
|
context.MethodInfo.DeclaringType.GetCustomAttributes(true)
|
|
.OfType<ProducesErrorsAttribute>()
|
|
.FirstOrDefault();
|
|
|
|
if (attribute == null) return;
|
|
|
|
var isMastodonController = context.MethodInfo.DeclaringType.GetCustomAttributes(true)
|
|
.OfType<MastodonApiControllerAttribute>()
|
|
.Any();
|
|
|
|
var type = isMastodonController ? typeof(MastodonErrorResponse) : typeof(ErrorResponse);
|
|
var schema = context.SchemaGenerator.GenerateSchema(type, context.SchemaRepository);
|
|
|
|
foreach (var status in attribute.StatusCodes.Distinct())
|
|
{
|
|
var res = new OpenApiResponse
|
|
{
|
|
Description = ReasonPhrases.GetReasonPhrase((int)status),
|
|
Content = { ["application/json"] = new OpenApiMediaType { Schema = schema } }
|
|
};
|
|
|
|
operation.Responses.Remove(((int)status).ToString());
|
|
operation.Responses.Add(((int)status).ToString(), res);
|
|
}
|
|
}
|
|
}
|
|
|
|
[SuppressMessage("ReSharper", "ClassNeverInstantiated.Local",
|
|
Justification = "SwaggerGenOptions.OperationFilter<T> instantiates this class at runtime")]
|
|
private class PossibleResultsOperationFilter : IOperationFilter
|
|
{
|
|
public void Apply(OpenApiOperation operation, OperationFilterContext context)
|
|
{
|
|
if (context.MethodInfo.DeclaringType is null)
|
|
return;
|
|
|
|
var attribute = context.MethodInfo.GetCustomAttributes(true)
|
|
.OfType<ProducesResultsAttribute>()
|
|
.FirstOrDefault() ??
|
|
context.MethodInfo.DeclaringType.GetCustomAttributes(true)
|
|
.OfType<ProducesResultsAttribute>()
|
|
.FirstOrDefault();
|
|
|
|
if (attribute == null) return;
|
|
|
|
var overrideType = context.MethodInfo.GetCustomAttributes(true)
|
|
.OfType<OverrideResultTypeAttribute>()
|
|
.FirstOrDefault() ??
|
|
context.MethodInfo.DeclaringType.GetCustomAttributes(true)
|
|
.OfType<OverrideResultTypeAttribute>()
|
|
.FirstOrDefault();
|
|
|
|
var type = overrideType?.Type ??
|
|
context.ApiDescription.SupportedResponseTypes.FirstOrDefault(p => p.Type != typeof(void))?.Type;
|
|
|
|
var schema = type != null
|
|
? context.SchemaGenerator.GenerateSchema(type, context.SchemaRepository)
|
|
: null;
|
|
|
|
var openApiMediaType = new OpenApiMediaType { Schema = schema };
|
|
foreach (var status in attribute.StatusCodes.Distinct())
|
|
{
|
|
var content = schema != null
|
|
? context.ApiDescription.SupportedResponseTypes
|
|
.Where(p => p.StatusCode == (int)status)
|
|
.SelectMany(p => p.ApiResponseFormats.Select(i => i.MediaType))
|
|
.Distinct()
|
|
.ToDictionary(contentType => contentType, _ => openApiMediaType)
|
|
: null;
|
|
|
|
var res = new OpenApiResponse
|
|
{
|
|
Description = ReasonPhrases.GetReasonPhrase((int)status), Content = content
|
|
};
|
|
|
|
operation.Responses.Remove(((int)status).ToString());
|
|
operation.Responses.Add(((int)status).ToString(), res);
|
|
}
|
|
}
|
|
}
|
|
|
|
[SuppressMessage("ReSharper", "ClassNeverInstantiated.Local",
|
|
Justification = "SwaggerGenOptions.OperationFilter<T> instantiates this class at runtime")]
|
|
private class HybridRequestOperationFilter : IOperationFilter
|
|
{
|
|
public void Apply(OpenApiOperation operation, OperationFilterContext context)
|
|
{
|
|
if (context.ApiDescription.ParameterDescriptions.All(p => p.Source != HybridBindingSource.Hybrid))
|
|
return;
|
|
|
|
operation.RequestBody =
|
|
GenerateRequestBody(context.ApiDescription, context.SchemaRepository, context.SchemaGenerator);
|
|
operation.Parameters.Clear();
|
|
}
|
|
|
|
private static OpenApiRequestBody? GenerateRequestBody(
|
|
ApiDescription apiDescription,
|
|
SchemaRepository schemaRepository,
|
|
ISchemaGenerator schemaGenerator
|
|
)
|
|
{
|
|
OpenApiRequestBody? requestBody = null;
|
|
|
|
var hybridParameter = apiDescription.ParameterDescriptions.FirstOrDefault(paramDesc =>
|
|
paramDesc.Source == HybridBindingSource.Hybrid);
|
|
|
|
if (hybridParameter != null)
|
|
requestBody =
|
|
GenerateRequestBodyFromHybridParameter(schemaRepository, schemaGenerator, hybridParameter);
|
|
|
|
return requestBody;
|
|
}
|
|
|
|
private static OpenApiRequestBody GenerateRequestBodyFromHybridParameter(
|
|
SchemaRepository schemaRepository,
|
|
ISchemaGenerator schemaGenerator,
|
|
ApiParameterDescription bodyParameter
|
|
)
|
|
{
|
|
List<string> contentTypes =
|
|
[
|
|
"application/json", "application/x-www-form-urlencoded", "multipart/form-data"
|
|
];
|
|
|
|
var isRequired = bodyParameter.IsRequiredParameter();
|
|
|
|
var schema = GenerateSchema(bodyParameter.ModelMetadata.ModelType,
|
|
schemaRepository,
|
|
schemaGenerator,
|
|
bodyParameter.PropertyInfo(),
|
|
bodyParameter.ParameterInfo());
|
|
|
|
return new OpenApiRequestBody
|
|
{
|
|
Content = contentTypes
|
|
.ToDictionary(contentType => contentType, _ => new OpenApiMediaType { Schema = schema }),
|
|
Required = isRequired
|
|
};
|
|
}
|
|
|
|
private static OpenApiSchema GenerateSchema(
|
|
Type type,
|
|
SchemaRepository schemaRepository,
|
|
ISchemaGenerator schemaGenerator,
|
|
MemberInfo? propertyInfo = null,
|
|
ParameterInfo? parameterInfo = null,
|
|
ApiParameterRouteInfo? routeInfo = null
|
|
)
|
|
{
|
|
try
|
|
{
|
|
return schemaGenerator.GenerateSchema(type, schemaRepository, propertyInfo, parameterInfo, routeInfo);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
throw new
|
|
SwaggerGeneratorException($"Failed to generate schema for type - {type}. See inner exception",
|
|
ex);
|
|
}
|
|
}
|
|
}
|
|
|
|
public class SwaggerBodyExampleAttribute(string value) : Attribute
|
|
{
|
|
public string Value => value;
|
|
}
|
|
} |