tgstation-server 6.14.0
The /tg/station 13 server suite
Loading...
Searching...
No Matches
SwaggerConfiguration.cs
Go to the documentation of this file.
1using System;
2using System.Collections;
3using System.Collections.Generic;
4using System.Linq;
5using System.Net;
6using System.Net.Mime;
7using System.Reflection;
8using System.Text.RegularExpressions;
9
10using Microsoft.Extensions.DependencyInjection;
11using Microsoft.Net.Http.Headers;
12using Microsoft.OpenApi.Any;
13using Microsoft.OpenApi.Models;
14
15using Swashbuckle.AspNetCore.SwaggerGen;
16
23
25{
30 {
34 public const string DocumentName = "tgs_api";
35
39 public const string DocumentationSiteRouteExtension = "documentation";
40
44 const string PasswordSecuritySchemeId = "Password_Login_Scheme";
45
49 const string OAuthSecuritySchemeId = "OAuth_Login_Scheme";
50
54 const string TokenSecuritySchemeId = "Token_Authorization_Scheme";
55
62 public static void Configure(SwaggerGenOptions swaggerGenOptions, string assemblyDocumentationPath, string apiDocumentationPath)
63 {
64 swaggerGenOptions.SwaggerDoc(
66 new OpenApiInfo
67 {
68 Title = "TGS API",
69 Version = ApiHeaders.Version.Semver().ToString(),
70 License = new OpenApiLicense
71 {
72 Name = "AGPL-3.0",
73 Url = new Uri("https://github.com/tgstation/tgstation-server/blob/dev/LICENSE"),
74 },
75 Contact = new OpenApiContact
76 {
77 Name = "/tg/station 13",
78 Url = new Uri("https://github.com/tgstation"),
79 },
80 Description = "A production scale tool for DreamMaker server management",
81 });
82
83 // Important to do this before applying our own filters
84 // Otherwise we'll get NullReferenceExceptions on parameters to be setup in our document filter
85 swaggerGenOptions.IncludeXmlComments(assemblyDocumentationPath);
86 swaggerGenOptions.IncludeXmlComments(apiDocumentationPath);
87
88 // nullable stuff
89 swaggerGenOptions.UseAllOfToExtendReferenceSchemas();
90
91 swaggerGenOptions.OperationFilter<SwaggerConfiguration>();
92 swaggerGenOptions.DocumentFilter<SwaggerConfiguration>();
93 swaggerGenOptions.SchemaFilter<SwaggerConfiguration>();
94 swaggerGenOptions.RequestBodyFilter<SwaggerConfiguration>();
95
96 swaggerGenOptions.CustomSchemaIds(GenerateSchemaId);
97
98 swaggerGenOptions.AddSecurityDefinition(PasswordSecuritySchemeId, new OpenApiSecurityScheme
99 {
100 In = ParameterLocation.Header,
101 Type = SecuritySchemeType.Http,
102 Name = HeaderNames.Authorization,
103 Description = "Username & Password authentication to obtain an JWT token when doing a (POST /api).",
105 });
106
107 swaggerGenOptions.AddSecurityDefinition(OAuthSecuritySchemeId, new OpenApiSecurityScheme
108 {
109 In = ParameterLocation.Header,
110 Type = SecuritySchemeType.Http,
111 Name = HeaderNames.Authorization,
112 Description = "OAuth2 based authentication.",
114 });
115
116 swaggerGenOptions.AddSecurityDefinition(TokenSecuritySchemeId, new OpenApiSecurityScheme
117 {
118 In = ParameterLocation.Header,
119 Type = SecuritySchemeType.Http,
120 Name = HeaderNames.Authorization,
121 Description = "JWT Bearer token generated by the server (POST /api). You need this for most endpoints to work.",
123 BearerFormat = "JWT",
124 });
125 }
126
131 static void AddDefaultResponses(OpenApiDocument document)
132 {
133 var errorMessageContent = new Dictionary<string, OpenApiMediaType>
134 {
135 {
136 MediaTypeNames.Application.Json,
137 new OpenApiMediaType
138 {
139 Schema = new OpenApiSchema
140 {
141 Reference = new OpenApiReference
142 {
143 Id = nameof(ErrorMessageResponse),
144 Type = ReferenceType.Schema,
145 },
146 },
147 }
148 },
149 };
150
151 void AddDefaultResponse(HttpStatusCode code, OpenApiResponse concrete)
152 {
153 string responseKey = $"{(int)code}";
154
155 document.Components.Responses.Add(responseKey, concrete);
156
157 var referenceResponse = new OpenApiResponse
158 {
159 Reference = new OpenApiReference
160 {
161 Type = ReferenceType.Response,
162 Id = responseKey,
163 },
164 };
165
166 foreach (var operation in document.Paths.SelectMany(path => path.Value.Operations))
167 operation.Value.Responses.TryAdd(responseKey, referenceResponse);
168 }
169
170 AddDefaultResponse(HttpStatusCode.BadRequest, new OpenApiResponse
171 {
172 Description = "A badly formatted request was made. See error message for details.",
173 Content = errorMessageContent,
174 });
175
176 AddDefaultResponse(HttpStatusCode.Unauthorized, new OpenApiResponse
177 {
178 Description = "Invalid Authentication header.",
179 });
180
181 AddDefaultResponse(HttpStatusCode.Forbidden, new OpenApiResponse
182 {
183 Description = "User lacks sufficient permissions for the operation.",
184 });
185
186 AddDefaultResponse(HttpStatusCode.Conflict, new OpenApiResponse
187 {
188 Description = "A data integrity check failed while performing the operation. See error message for details.",
189 Content = errorMessageContent,
190 });
191
192 AddDefaultResponse(HttpStatusCode.NotAcceptable, new OpenApiResponse
193 {
194 Description = $"Invalid Accept header, TGS requires `{HeaderNames.Accept}: {MediaTypeNames.Application.Json}`.",
195 Content = errorMessageContent,
196 });
197
198 AddDefaultResponse(HttpStatusCode.InternalServerError, new OpenApiResponse
199 {
200 Description = ErrorCode.InternalServerError.Describe(),
201 Content = errorMessageContent,
202 });
203
204 AddDefaultResponse(HttpStatusCode.ServiceUnavailable, new OpenApiResponse
205 {
206 Description = "The server may be starting up or shutting down.",
207 });
208
209 AddDefaultResponse(HttpStatusCode.NotImplemented, new OpenApiResponse
210 {
211 Description = ErrorCode.RequiresPosixSystemIdentity.Describe(),
212 Content = errorMessageContent,
213 });
214 }
215
221 static void ApplyAttributesForRootSchema(OpenApiSchema rootSchema, SchemaFilterContext context)
222 {
223 // tune up the descendants
224 rootSchema.Nullable = false;
225 var rootSchemaId = GenerateSchemaId(context.Type);
226 var rootRequestSchema = rootSchemaId.EndsWith("Request", StringComparison.Ordinal);
227 var rootResponseSchema = rootSchemaId.EndsWith("Response", StringComparison.Ordinal);
228 var isPutRequest = rootSchemaId.EndsWith("CreateRequest", StringComparison.Ordinal);
229
230 Tuple<PropertyInfo, string, OpenApiSchema, IDictionary<string, OpenApiSchema>> GetTypeFromKvp(Type currentType, KeyValuePair<string, OpenApiSchema> kvp, IDictionary<string, OpenApiSchema> schemaDictionary)
231 {
232 var propertyInfo = currentType
233 .GetProperties()
234 .Single(x => x.Name.Equals(kvp.Key, StringComparison.OrdinalIgnoreCase));
235
236 return Tuple.Create(
237 propertyInfo,
238 kvp.Key,
239 kvp.Value,
240 schemaDictionary);
241 }
242
243 var subSchemaStack = new Stack<Tuple<PropertyInfo, string, OpenApiSchema, IDictionary<string, OpenApiSchema>>>(
244 rootSchema
245 .Properties
246 .Select(
247 x => GetTypeFromKvp(context.Type, x, rootSchema.Properties))
248 .Where(x => x.Item3.Reference == null));
249
250 while (subSchemaStack.Count > 0)
251 {
252 var tuple = subSchemaStack.Pop();
253 var subSchema = tuple.Item3;
254
255 var subSchemaPropertyInfo = tuple.Item1;
256
257 if (subSchema.Properties != null
258 && !subSchemaPropertyInfo
259 .PropertyType
260 .GetInterfaces()
261 .Any(x => x == typeof(IEnumerable)))
262 foreach (var kvp in subSchema.Properties.Where(x => x.Value.Reference == null))
263 subSchemaStack.Push(GetTypeFromKvp(subSchemaPropertyInfo.PropertyType, kvp, subSchema.Properties));
264
265 var attributes = subSchemaPropertyInfo
266 .GetCustomAttributes();
267 var responsePresence = attributes
268 .OfType<ResponseOptionsAttribute>()
269 .FirstOrDefault()
270 ?.Presence
271 ?? FieldPresence.Required;
272 var requestOptions = attributes
273 .OfType<RequestOptionsAttribute>()
274 .OrderBy(x => x.PutOnly) // Process PUTs last
275 .ToList();
276
277 if (requestOptions.Count == 0 && requestOptions.All(x => x.Presence == FieldPresence.Ignored && !x.PutOnly))
278 subSchema.ReadOnly = true;
279
280 var subSchemaId = tuple.Item2;
281 var subSchemaOwningDictionary = tuple.Item4;
282 if (rootResponseSchema)
283 {
284 subSchema.Nullable = responsePresence == FieldPresence.Optional;
285 if (responsePresence == FieldPresence.Ignored)
286 subSchemaOwningDictionary.Remove(subSchemaId);
287 }
288 else if (rootRequestSchema)
289 {
290 subSchema.Nullable = true;
291 var lastOptionWasIgnored = false;
292 foreach (var requestOption in requestOptions)
293 {
294 var validForThisRequest = !requestOption.PutOnly || isPutRequest;
295 if (!validForThisRequest)
296 continue;
297
298 lastOptionWasIgnored = false;
299 switch (requestOption.Presence)
300 {
301 case FieldPresence.Ignored:
302 lastOptionWasIgnored = true;
303 break;
304 case FieldPresence.Optional:
305 subSchema.Nullable = true;
306 break;
307 case FieldPresence.Required:
308 subSchema.Nullable = false;
309 break;
310 default:
311 throw new InvalidOperationException($"Invalid FieldPresence: {requestOption.Presence}!");
312 }
313 }
314
315 if (lastOptionWasIgnored)
316 subSchemaOwningDictionary.Remove(subSchemaId);
317 }
318 else if (responsePresence == FieldPresence.Required
319 && requestOptions.All(x => x.Presence == FieldPresence.Required && !x.PutOnly))
320 subSchema.Nullable = subSchemaId.Equals(
322 StringComparison.OrdinalIgnoreCase)
323 && rootSchemaId == nameof(TestMergeParameters); // special tactics
324
325 // otherwise, we have to assume it's a shared schema
326 // use what Swagger thinks the nullability is by default
327 }
328 }
329
335 static string GenerateSchemaId(Type type)
336 {
337 if (type == typeof(UserName))
338 return "ShallowUserResponse";
339
340 if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(PaginatedResponse<>))
341 return $"Paginated{type.GenericTypeArguments.First().Name}";
342
343 return type.Name;
344 }
345
347 public void Apply(OpenApiOperation operation, OperationFilterContext context)
348 {
349 ArgumentNullException.ThrowIfNull(operation);
350 ArgumentNullException.ThrowIfNull(context);
351
352 operation.OperationId = $"{context.MethodInfo.DeclaringType!.Name}.{context.MethodInfo.Name}";
353
354 var authAttributes = context
355 .MethodInfo
356 .DeclaringType
357 .GetCustomAttributes(true)
358 .Union(
359 context
360 .MethodInfo
361 .GetCustomAttributes(true))
362 .OfType<TgsAuthorizeAttribute>();
363
364 if (authAttributes.Any())
365 {
366 var tokenScheme = new OpenApiSecurityScheme
367 {
368 Reference = new OpenApiReference
369 {
370 Type = ReferenceType.SecurityScheme,
372 },
373 };
374
375 operation.Security = new List<OpenApiSecurityRequirement>()
376 {
377 new()
378 {
379 { tokenScheme, new List<string>() },
380 },
381 };
382
383 if (typeof(InstanceRequiredController).IsAssignableFrom(context.MethodInfo.DeclaringType))
384 operation.Parameters.Insert(0, new OpenApiParameter
385 {
386 Reference = new OpenApiReference
387 {
388 Type = ReferenceType.Parameter,
390 },
391 });
392 else if (typeof(TransferController).IsAssignableFrom(context.MethodInfo.DeclaringType))
393 if (context.MethodInfo.Name == nameof(TransferController.Upload))
394 operation.RequestBody = new OpenApiRequestBody
395 {
396 Content = new Dictionary<string, OpenApiMediaType>
397 {
398 {
399 MediaTypeNames.Application.Octet,
400 new OpenApiMediaType
401 {
402 Schema = new OpenApiSchema
403 {
404 Type = "string",
405 Format = "binary",
406 },
407 }
408 },
409 },
410 };
411 else if (context.MethodInfo.Name == nameof(TransferController.Download))
412 {
413 var twoHundredResponseContents = operation.Responses["200"].Content;
414 var fileContent = twoHundredResponseContents[MediaTypeNames.Application.Json];
415 twoHundredResponseContents.Remove(MediaTypeNames.Application.Json);
416 twoHundredResponseContents.Add(MediaTypeNames.Application.Octet, fileContent);
417 }
418 }
419 else if (context.MethodInfo.Name == nameof(ApiRootController.CreateToken))
420 {
421 var passwordScheme = new OpenApiSecurityScheme
422 {
423 Reference = new OpenApiReference
424 {
425 Type = ReferenceType.SecurityScheme,
427 },
428 };
429
430 var oAuthScheme = new OpenApiSecurityScheme
431 {
432 Reference = new OpenApiReference
433 {
434 Type = ReferenceType.SecurityScheme,
436 },
437 };
438
439 operation.Parameters.Add(new OpenApiParameter
440 {
441 In = ParameterLocation.Header,
443 Description = "The external OAuth service provider.",
444 Style = ParameterStyle.Simple,
445 Example = new OpenApiString("Discord"),
446 Schema = new OpenApiSchema
447 {
448 Type = "string",
449 },
450 });
451
452 operation.Security = new List<OpenApiSecurityRequirement>
453 {
454 new()
455 {
456 {
457 passwordScheme,
458 new List<string>()
459 },
460 {
461 oAuthScheme,
462 new List<string>()
463 },
464 },
465 };
466 }
467 }
468
470 public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context)
471 {
472 ArgumentNullException.ThrowIfNull(swaggerDoc);
473 ArgumentNullException.ThrowIfNull(context);
474
475 swaggerDoc.ExternalDocs = new OpenApiExternalDocs
476 {
477 Description = "API Usage Documentation",
478 Url = new Uri("https://tgstation.github.io/tgstation-server/api.html"),
479 };
480
481 swaggerDoc.Components.Parameters.Add(ApiHeaders.InstanceIdHeader, new OpenApiParameter
482 {
483 In = ParameterLocation.Header,
484 Name = ApiHeaders.InstanceIdHeader,
485 Description = "The instance ID being accessed",
486 Required = true,
487 Style = ParameterStyle.Simple,
488 Schema = new OpenApiSchema
489 {
490 Type = "integer",
491 },
492 });
493
494 var productHeaderSchema = new OpenApiSchema
495 {
496 Type = "string",
497 };
498
499 swaggerDoc.Components.Parameters.Add(ApiHeaders.ApiVersionHeader, new OpenApiParameter
500 {
501 In = ParameterLocation.Header,
502 Name = ApiHeaders.ApiVersionHeader,
503 Description = "The API version being used in the form \"Tgstation.Server.Api/[API version]\"",
504 Required = true,
505 Style = ParameterStyle.Simple,
506 Example = new OpenApiString($"Tgstation.Server.Api/{ApiHeaders.Version}"),
507 Schema = productHeaderSchema,
508 });
509
510 swaggerDoc.Components.Parameters.Add(HeaderNames.UserAgent, new OpenApiParameter
511 {
512 In = ParameterLocation.Header,
513 Name = HeaderNames.UserAgent,
514 Description = "The user agent of the calling client.",
515 Required = true,
516 Style = ParameterStyle.Simple,
517 Example = new OpenApiString("Your-user-agent/1.0.0.0"),
518 Schema = productHeaderSchema,
519 });
520
521 var allSchemas = context
522 .SchemaRepository
523 .Schemas;
524 foreach (var path in swaggerDoc.Paths)
525 foreach (var operation in path.Value.Operations.Select(x => x.Value))
526 {
527 operation.Parameters.Insert(0, new OpenApiParameter
528 {
529 Reference = new OpenApiReference
530 {
531 Type = ReferenceType.Parameter,
533 },
534 });
535
536 operation.Parameters.Insert(1, new OpenApiParameter
537 {
538 Reference = new OpenApiReference
539 {
540 Type = ReferenceType.Parameter,
541 Id = HeaderNames.UserAgent,
542 },
543 });
544
545 // flatten (Tgstation.Server.*).MyClass
546 if (operation.Description?.Length > 0)
547 operation.Description = Regex.Replace(operation.Description, @"Tgstation\.Server\.(\w+\.)+(?=\w+)", string.Empty);
548
549 if (operation.Summary?.Length > 0)
550 operation.Summary = Regex.Replace(operation.Summary, @"Tgstation\.Server\.(\w+\.)+(?=\w+)", string.Empty);
551
552 if (operation.RequestBody?.Description != null)
553 operation.RequestBody.Description = Regex.Replace(operation.RequestBody.Description, @"Tgstation\.Server\.(\w+\.)+(?=\w+)", string.Empty);
554
555 foreach (var opPar in operation.Parameters)
556 {
557 if (opPar.Description != null)
558 opPar.Description = Regex.Replace(opPar.Description, @"Tgstation\.Server\.(\w+\.)+(?=\w+)", string.Empty);
559 }
560
561 foreach (var (_, opRes) in operation.Responses)
562 {
563 if (opRes.Description != null)
564 opRes.Description = Regex.Replace(opRes.Description, @"Tgstation\.Server\.(\w+\.)+(?=\w+)", string.Empty);
565 }
566 }
567
568 AddDefaultResponses(swaggerDoc);
569 }
570
572 public void Apply(OpenApiSchema schema, SchemaFilterContext context)
573 {
574 ArgumentNullException.ThrowIfNull(schema);
575 ArgumentNullException.ThrowIfNull(context);
576
577 // Nothing is required
578 schema.Required.Clear();
579
580 if (context.MemberInfo == null)
581 ApplyAttributesForRootSchema(schema, context);
582
583 if (!schema.Enum?.Any() ?? false)
584 return;
585
586 // Could be nullable type, make sure to get the right one
587 Type firstGenericArgumentOrType = context.Type.IsConstructedGenericType
588 ? context.Type.GenericTypeArguments.First()
589 : context.Type;
590
591 OpenApiEnumVarNamesExtension.Apply(schema, firstGenericArgumentOrType);
592 }
593
595 public void Apply(OpenApiRequestBody requestBody, RequestBodyFilterContext context)
596 {
597 ArgumentNullException.ThrowIfNull(requestBody);
598 ArgumentNullException.ThrowIfNull(context);
599
600 requestBody.Required = true;
601 }
602 }
603}
Represents the header that must be present for every server request.
Definition ApiHeaders.cs:25
const string OAuthAuthenticationScheme
The JWT authentication header scheme.
Definition ApiHeaders.cs:54
const string InstanceIdHeader
The InstanceId header key.
Definition ApiHeaders.cs:34
static readonly Version Version
Get the version of the Api the caller is using.
Definition ApiHeaders.cs:69
const string BasicAuthenticationScheme
The JWT authentication header scheme.
Definition ApiHeaders.cs:49
const string ApiVersionHeader
The ApiVersion header key.
Definition ApiHeaders.cs:29
const string BearerAuthenticationScheme
The JWT authentication header scheme.
Definition ApiHeaders.cs:44
const string OAuthProviderHeader
The OAuthProvider header key.
Definition ApiHeaders.cs:39
Indicates the FieldPresence for fields in models.
Represents an error message returned by the server.
Indicates the response FieldPresence of API fields. Changes it from FieldPresence....
virtual ? string TargetCommitSha
The sha of the test merge revision to merge. If not specified, the latest commit from the source will...
Base class for user names.
Definition UserName.cs:7
Root ApiController for the Application.
ValueTask< IActionResult > CreateToken(CancellationToken cancellationToken)
Attempt to authenticate a User using ApiController.ApiHeaders.
ComponentInterfacingController for operations that require an instance.
ValueTask< IActionResult > Download([Required, FromQuery] string ticket, CancellationToken cancellationToken)
Downloads a file with a given ticket .
async ValueTask< IActionResult > Upload([Required, FromQuery] string ticket, CancellationToken cancellationToken)
Uploads a file with a given ticket .
Helper for using the AuthorizeAttribute with the Api.Rights system.
Implements the "x-enum-varnames" OpenAPI 3.0 extension.
static void Apply(OpenApiSchema openApiSchema, Type enumType)
Applies the extension to a give openApiSchema .
Implements various filters for Swashbuckle.
static string GenerateSchemaId(Type type)
Generates the OpenAPI schema ID for a given type .
void Apply(OpenApiRequestBody requestBody, RequestBodyFilterContext context)
const string OAuthSecuritySchemeId
The OpenApiSecurityScheme name for OAuth 2.0 authentication.
const string DocumentationSiteRouteExtension
The path to the hosted documentation site.
void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context)
void Apply(OpenApiSchema schema, SchemaFilterContext context)
static void Configure(SwaggerGenOptions swaggerGenOptions, string assemblyDocumentationPath, string apiDocumentationPath)
Configure the swagger settings.
const string PasswordSecuritySchemeId
The OpenApiSecurityScheme name for password authentication.
const string TokenSecuritySchemeId
The OpenApiSecurityScheme name for token authentication.
const string DocumentName
The name of the swagger document.
static void AddDefaultResponses(OpenApiDocument document)
Add the default error responses to a given document .
void Apply(OpenApiOperation operation, OperationFilterContext context)
static void ApplyAttributesForRootSchema(OpenApiSchema rootSchema, SchemaFilterContext context)
Applies the OpenApiSchema.Nullable, OpenApiSchema.ReadOnly, and OpenApiSchema.WriteOnly to OpenApiSch...
FieldPresence
Indicates whether a request field is Required or Ignored.