diff --git a/src/Tgstation.Server.Host/Controllers/TgsOpenApiFilters.cs b/src/Tgstation.Server.Host/Controllers/TgsOpenApiFilters.cs new file mode 100644 index 0000000000..f158398efe --- /dev/null +++ b/src/Tgstation.Server.Host/Controllers/TgsOpenApiFilters.cs @@ -0,0 +1,244 @@ +using Microsoft.Net.Http.Headers; +using Microsoft.OpenApi.Any; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Net; +using Tgstation.Server.Api; +using Tgstation.Server.Api.Models; +using Tgstation.Server.Api.Rights; + +namespace Tgstation.Server.Host.Controllers +{ + /// + /// and for the server. + /// + sealed class TgsOpenApiFilters : IOperationFilter, IDocumentFilter + { + /// + /// The name for password authentication. + /// + public const string PasswordSecuritySchemeId = "Password_Login_Scheme"; + + /// + /// The name for token authentication. + /// + public const string TokenSecuritySchemeId = "Token_Authorization_Scheme"; + + const string InstanceIdParameterId = "Instance_ID_Parameter"; + const string ApiVersionParameterId = "Api_Version_Parameter"; + const string UserAgentParameterId = "User_Agent_Parameter"; + + readonly ICollection operationsToAddInstanceIdReferenceTo; + + /// + /// Initializes a new instance of the . + /// + public TgsOpenApiFilters() + { + operationsToAddInstanceIdReferenceTo = new List(); + } + + /// + public void Apply(OpenApiOperation operation, OperationFilterContext context) + { + if (operation == null) + throw new ArgumentNullException(nameof(operation)); + if (context == null) + throw new ArgumentNullException(nameof(context)); + + var authAttributes = context + .MethodInfo + .DeclaringType + .GetCustomAttributes(true) + .Union( + context + .MethodInfo + .GetCustomAttributes(true)) + .OfType(); + + if (authAttributes.Any()) + { + var tokenScheme = new OpenApiSecurityScheme + { + Reference = new OpenApiReference + { + Type = ReferenceType.SecurityScheme, + Id = TokenSecuritySchemeId + } + }; + + operation.Security = new List + { + new OpenApiSecurityRequirement + { + { + tokenScheme, + new List() + } + } + }; + + if (authAttributes.Any(attr => attr.RightsType.HasValue && RightsHelper.IsInstanceRight(attr.RightsType.Value))) + operation.Parameters.Add(new OpenApiParameter + { + Reference = new OpenApiReference + { + Type = ReferenceType.Header, + Id = ApiHeaders.InstanceIdHeader + } + }); + } + else + { + // HomeController.CreateToken + var passwordScheme = new OpenApiSecurityScheme + { + Reference = new OpenApiReference + { + Type = ReferenceType.SecurityScheme, + Id = PasswordSecuritySchemeId + } + }; + + operation.Security = new List + { + new OpenApiSecurityRequirement + { + { + passwordScheme, + new List() + } + } + }; + } + } + + /// + public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context) + { + swaggerDoc.Components.Headers.Add(ApiHeaders.InstanceIdHeader, new OpenApiHeader + { + Description = "The instance ID being accessed", + Required = true, + Style = ParameterStyle.Simple + }); + + operationsToAddInstanceIdReferenceTo.Clear(); + + swaggerDoc.Components.Headers.Add(ApiHeaders.ApiVersionHeader, new OpenApiHeader + { + Description = "The API version being used in the form \"Tgstation.Server.Api/[API version]\"", + Required = true, + Style = ParameterStyle.Simple, + Example = new OpenApiString($"Tgstation.Server.Api/{ApiHeaders.Version}") + }); + + swaggerDoc.Components.Headers.Add(HeaderNames.UserAgent, new OpenApiHeader + { + Description = "The user agent of the calling client.", + Required = true, + Style = ParameterStyle.Simple, + Example = new OpenApiString("Your-user-agent/1.0.0.0") + }); + + foreach (var operation in swaggerDoc + .Paths + .SelectMany(path => path.Value.Operations) + .Select(kvp => kvp.Value)) + { + operation.Parameters.Add(new OpenApiParameter + { + Reference = new OpenApiReference + { + Type = ReferenceType.Header, + Id = ApiHeaders.ApiVersionHeader + } + }); + + operation.Parameters.Add(new OpenApiParameter + { + Reference = new OpenApiReference + { + Type = ReferenceType.Header, + Id = HeaderNames.UserAgent + } + }); + } + + var errorMessageContent = new Dictionary + { + { + ApiHeaders.ApplicationJson, + new OpenApiMediaType + { + Schema = new OpenApiSchema + { + Reference = new OpenApiReference + { + Id = nameof(ErrorMessage), + Type = ReferenceType.Schema + } + } + } + } + }; + + void AddDefaultResponse(HttpStatusCode code, OpenApiResponse concrete) + { + string responseKey = $"{(int)code}"; + + swaggerDoc.Components.Responses.Add(responseKey, concrete); + + var referenceResponse = new OpenApiResponse + { + Reference = new OpenApiReference + { + Type = ReferenceType.Response, + Id = responseKey + } + }; + + foreach (var path in swaggerDoc.Paths) + foreach (var operation in path.Value.Operations) + operation.Value.Responses.TryAdd(responseKey, referenceResponse); + } + + AddDefaultResponse(HttpStatusCode.BadRequest, new OpenApiResponse + { + Description = "A badly formatted request was made. See error message for details.", + Content = errorMessageContent, + }); + + AddDefaultResponse(HttpStatusCode.Unauthorized, new OpenApiResponse + { + Description = "No/invalid token provided." + }); + + AddDefaultResponse(HttpStatusCode.Forbidden, new OpenApiResponse + { + Description = "User lacks sufficient permissions for the operation." + }); + + AddDefaultResponse(HttpStatusCode.Conflict, new OpenApiResponse + { + Description = "A data integrity check failed while performing the operation. See error message for details.", + Content = errorMessageContent + }); + + AddDefaultResponse(HttpStatusCode.InternalServerError, new OpenApiResponse + { + Description = "The server encountered an unhandled error. See error message for details.", + Content = errorMessageContent + }); + + AddDefaultResponse(HttpStatusCode.ServiceUnavailable, new OpenApiResponse + { + Description = "The server may be starting up or shutting down." + }); + } + } +} diff --git a/src/Tgstation.Server.Host/Controllers/TgsOperationFilter.cs b/src/Tgstation.Server.Host/Controllers/TgsOperationFilter.cs deleted file mode 100644 index 49f4fba1ce..0000000000 --- a/src/Tgstation.Server.Host/Controllers/TgsOperationFilter.cs +++ /dev/null @@ -1,168 +0,0 @@ -using Microsoft.OpenApi.Any; -using Microsoft.OpenApi.Models; -using Swashbuckle.AspNetCore.SwaggerGen; -using System; -using System.Collections.Generic; -using System.Linq; -using Tgstation.Server.Api; -using Tgstation.Server.Api.Models; -using Tgstation.Server.Api.Rights; - -namespace Tgstation.Server.Host.Controllers -{ - /// - /// for the server. - /// - sealed class TgsOperationFilter : IOperationFilter - { - /// - /// The name for password authentication. - /// - public const string PasswordSecuritySchemeId = "Password_Login"; - - /// - /// The name for token authentication. - /// - public const string TokenSecuritySchemeId = "Token_Authorization"; - - /// - public void Apply(OpenApiOperation operation, OperationFilterContext context) - { - if (operation == null) - throw new ArgumentNullException(nameof(operation)); - if (context == null) - throw new ArgumentNullException(nameof(context)); - - var authAttributes = context - .MethodInfo - .DeclaringType - .GetCustomAttributes(true) - .Union( - context - .MethodInfo - .GetCustomAttributes(true)) - .OfType(); - - // stub var because debugger conditions are bad - if (authAttributes.Any()) - { - var tokenScheme = new OpenApiSecurityScheme - { - Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = TokenSecuritySchemeId } - }; - - operation.Security = new List - { - new OpenApiSecurityRequirement - { - { - tokenScheme, - new List() - } - } - }; - - if (authAttributes.Any(attr => attr.RightsType.HasValue && RightsHelper.IsInstanceRight(attr.RightsType.Value))) - operation.Parameters.Add(new OpenApiParameter - { - In = ParameterLocation.Header, - Description = "The instance ID being accessed", - Name = ApiHeaders.InstanceIdHeader, - Required = true, - Style = ParameterStyle.Simple - }); - } - else - { - // HomeController.CreateToken - var passwordScheme = new OpenApiSecurityScheme - { - Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = PasswordSecuritySchemeId } - }; - - operation.Security = new List - { - new OpenApiSecurityRequirement - { - { - passwordScheme, - new List() - } - } - }; - } - - operation.Parameters.Add(new OpenApiParameter - { - In = ParameterLocation.Header, - Description = "The API version being used in the form \"Tgstation.Server.Api/[API version]\"", - Name = ApiHeaders.ApiVersionHeader, - Required = true, - Style = ParameterStyle.Simple, - Example = new OpenApiString($"Tgstation.Server.Api/{ApiHeaders.Version}") - }); - - operation.Parameters.Add(new OpenApiParameter - { - In = ParameterLocation.Header, - Description = "The user agent of the calling client.", - Name = "User-Agent", - Required = true, - Style = ParameterStyle.Simple, - Example = new OpenApiString("Your-user-agent/1.0.0.0") - }); - - var errorMessageContent = new Dictionary - { - { - ApiHeaders.ApplicationJson, - new OpenApiMediaType - { - Schema = new OpenApiSchema - { - Reference = new OpenApiReference - { - Id = nameof(ErrorMessage), - Type = ReferenceType.Schema - } - } - } - } - }; - - // Add default common status codes - operation.Responses.TryAdd("400", new OpenApiResponse - { - Description = "A badly formatted request was made. See error message for details.", - Content = errorMessageContent - }); - - operation.Responses.TryAdd("401", new OpenApiResponse - { - Description = "No/invalid token provided." - }); - - operation.Responses.TryAdd("403", new OpenApiResponse - { - Description = "User lacks sufficient permissions for the operation." - }); - - operation.Responses.TryAdd("409", new OpenApiResponse - { - Description = "A data integrity check failed while performing the operation. See error message for details.", - Content = errorMessageContent - }); - - operation.Responses.TryAdd("500", new OpenApiResponse - { - Description = "The server encountered an unhandled error. See error message for details.", - Content = errorMessageContent - }); - - operation.Responses.TryAdd("503", new OpenApiResponse - { - Description = "The server may be starting up or shutting down." - }); - } - } -} diff --git a/src/Tgstation.Server.Host/Core/Application.cs b/src/Tgstation.Server.Host/Core/Application.cs index 63ef876d7c..9939ea6778 100644 --- a/src/Tgstation.Server.Host/Core/Application.cs +++ b/src/Tgstation.Server.Host/Core/Application.cs @@ -256,9 +256,16 @@ namespace Tgstation.Server.Host.Core Version = "v4" }); - c.OperationFilter(); + // Important to do this before applying our own filters + // Otherwise we'll get NullReferenceExceptions on parameters to be setup in our document filter + var assemblyLocation = Assembly.GetExecutingAssembly().Location; + var filePath = ioManager.ConcatPath(ioManager.GetDirectoryName(assemblyLocation), String.Concat(ioManager.GetFileNameWithoutExtension(assemblyLocation), ".xml")); + c.IncludeXmlComments(filePath); - c.AddSecurityDefinition(TgsOperationFilter.PasswordSecuritySchemeId, new OpenApiSecurityScheme + c.OperationFilter(); + c.DocumentFilter(); + + c.AddSecurityDefinition(TgsOpenApiFilters.PasswordSecuritySchemeId, new OpenApiSecurityScheme { In = ParameterLocation.Header, Type = SecuritySchemeType.Http, @@ -266,7 +273,7 @@ namespace Tgstation.Server.Host.Core Scheme = ApiHeaders.BasicAuthenticationScheme }); - c.AddSecurityDefinition(TgsOperationFilter.TokenSecuritySchemeId, new OpenApiSecurityScheme + c.AddSecurityDefinition(TgsOpenApiFilters.TokenSecuritySchemeId, new OpenApiSecurityScheme { BearerFormat = "JWT", In = ParameterLocation.Header, @@ -274,9 +281,6 @@ namespace Tgstation.Server.Host.Core Name = HeaderNames.Authorization, Scheme = ApiHeaders.JwtAuthenticationScheme }); - - var filePath = ioManager.ConcatPath(ioManager.GetDirectoryName(Assembly.GetExecutingAssembly().Location), "Tgstation.Server.Host.xml"); - c.IncludeXmlComments(filePath); }); // enable browser detection