tgstation-server 5.12.7
The /tg/station 13 server suite
Loading...
Searching...
No Matches
ApiController.cs
Go to the documentation of this file.
1using System;
2using System.Collections.Generic;
3using System.Globalization;
4using System.Linq;
5using System.Net;
6using System.Net.Mime;
7using System.Threading;
8using System.Threading.Tasks;
9
10using Microsoft.AspNetCore.Http;
11using Microsoft.AspNetCore.Mvc;
12using Microsoft.AspNetCore.Mvc.Filters;
13using Microsoft.EntityFrameworkCore;
14using Microsoft.EntityFrameworkCore.Query;
15using Microsoft.Extensions.Logging;
16using Microsoft.Net.Http.Headers;
17
18using Octokit;
19
20using Serilog.Context;
21
30
32{
36 [Produces(MediaTypeNames.Application.Json)]
37 [ApiController]
38 public abstract class ApiController : Controller
39 {
43 private const ushort DefaultPageSize = 10;
44
48 private const ushort MaximumPageSize = 100;
49
53 protected ApiHeaders ApiHeaders { get; private set; }
54
59
64
68 protected ILogger<ApiController> Logger { get; }
69
73 protected Models.Instance Instance { get; }
74
78 readonly bool requireHeaders;
79
87 protected ApiController(
88 IDatabaseContext databaseContext,
89 IAuthenticationContextFactory authenticationContextFactory,
90 ILogger<ApiController> logger,
91 bool requireHeaders)
92 {
93 DatabaseContext = databaseContext ?? throw new ArgumentNullException(nameof(databaseContext));
94 ArgumentNullException.ThrowIfNull(authenticationContextFactory);
95 Logger = logger ?? throw new ArgumentNullException(nameof(logger));
96 AuthenticationContext = authenticationContextFactory.CurrentAuthenticationContext;
98 this.requireHeaders = requireHeaders;
99 }
100
102#pragma warning disable CA1506 // TODO: Decomplexify
103 public override async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
104 {
105 ArgumentNullException.ThrowIfNull(context);
106
107 // ALL valid token and login requests that match a route go through this function
108 // 404 is returned before
109 if (AuthenticationContext != null && AuthenticationContext.User == null)
110 {
111 // valid token, expired password
112 await Unauthorized().ExecuteResultAsync(context);
113 return;
114 }
115
116 // validate the headers
117 try
118 {
119 ApiHeaders = new ApiHeaders(Request.GetTypedHeaders());
120
121 if (!ApiHeaders.Compatible())
122 {
123 await this.StatusCode(
124 HttpStatusCode.UpgradeRequired,
125 new ErrorMessageResponse(ErrorCode.ApiMismatch))
126 .ExecuteResultAsync(context);
127 return;
128 }
129
130 var errorCase = await ValidateRequest(context.HttpContext.RequestAborted);
131 if (errorCase != null)
132 {
133 await errorCase.ExecuteResultAsync(context);
134 return;
135 }
136 }
137 catch (HeadersException)
138 {
139 if (requireHeaders)
140 {
141 await HeadersIssue(false)
142 .ExecuteResultAsync(context);
143 return;
144 }
145 }
146
147 if (ModelState?.IsValid == false)
148 {
149 var errorMessages = ModelState
150 .SelectMany(x => x.Value.Errors)
151 .Select(x => x.ErrorMessage)
152
153 // We use RequiredAttributes purely for preventing properties from becoming nullable in the databases
154 // We validate missing required fields in controllers
155 // Unfortunately, we can't remove the whole validator for that as it checks other things like StringLength
156 // This is the best way to deal with it unfortunately
157 .Where(x => !x.EndsWith(" field is required.", StringComparison.Ordinal));
158
159 if (errorMessages.Any())
160 {
161 await BadRequest(
162 new ErrorMessageResponse(ErrorCode.ModelValidationFailure)
163 {
164 AdditionalData = String.Join(Environment.NewLine, errorMessages),
165 })
166 .ExecuteResultAsync(context);
167 return;
168 }
169
170 ModelState.Clear();
171 }
172
173 using (ApiHeaders?.InstanceId != null
175 : null)
176 using (AuthenticationContext != null
178 : null)
179 using (LogContext.PushProperty(SerilogContextHelper.RequestPathContextProperty, $"{Request.Method} {Request.Path}"))
180 {
181 if (ApiHeaders != null)
182 {
183 var isGet = HttpMethods.IsGet(Request.Method);
184 if (!(isGet && Request.Path.StartsWithSegments(Routes.Jobs, StringComparison.OrdinalIgnoreCase)))
185 Logger.Log(
186 isGet
187 ? LogLevel.Trace
188 : LogLevel.Debug,
189 "Starting API request: Version: {clientApiVersion}. {userAgentHeaderName}: {clientUserAgent}",
190 ApiHeaders.ApiVersion.Semver(),
191 HeaderNames.UserAgent,
193 }
194 else if (Request.Headers.TryGetValue(HeaderNames.UserAgent, out var userAgents))
195 Logger.LogDebug(
196 "Starting unauthorized API request. {userAgentHeaderName}: {allUserAgents}",
197 HeaderNames.UserAgent,
198 userAgents);
199 else
200 Logger.LogDebug(
201 "Starting unauthorized API request. No {userAgentHeaderName}!",
202 HeaderNames.UserAgent);
203 await base.OnActionExecutionAsync(context, next);
204 }
205 }
206#pragma warning restore CA1506
207
212 protected new NotFoundObjectResult NotFound() => NotFound(new ErrorMessageResponse(ErrorCode.ResourceNeverPresent));
213
219 protected ObjectResult RequiresPosixSystemIdentity(NotImplementedException ex)
220 {
221 Logger.LogTrace(ex, "System identities not implemented!");
222 return this.StatusCode(HttpStatusCode.NotImplemented, new ErrorMessageResponse(ErrorCode.RequiresPosixSystemIdentity));
223 }
224
230 protected StatusCodeResult StatusCode(HttpStatusCode statusCode) => StatusCode((int)statusCode);
231
237 protected ObjectResult Created(object payload) => StatusCode((int)HttpStatusCode.Created, payload);
238
244 protected ObjectResult RateLimit(RateLimitExceededException rateLimitException)
245 {
246 ArgumentNullException.ThrowIfNull(rateLimitException);
247
248 Logger.LogWarning(rateLimitException, "Exceeded GitHub rate limit!");
249
250 var secondsString = Math.Ceiling(rateLimitException.GetRetryAfterTimeSpan().TotalSeconds).ToString(CultureInfo.InvariantCulture);
251 Response.Headers.Add(HeaderNames.RetryAfter, secondsString);
252 return this.StatusCode(HttpStatusCode.TooManyRequests, new ErrorMessageResponse(ErrorCode.GitHubApiRateLimit));
253 }
254
260 protected virtual Task<IActionResult> ValidateRequest(CancellationToken cancellationToken)
261 => Task.FromResult<IActionResult>(null);
262
268 protected IActionResult HeadersIssue(bool ignoreMissingAuth)
269 {
270 HeadersException headersException;
271 try
272 {
273 _ = new ApiHeaders(Request.GetTypedHeaders(), ignoreMissingAuth);
274 throw new InvalidOperationException("Expected a header parse exception!");
275 }
276 catch (HeadersException ex)
277 {
278 headersException = ex;
279 }
280
281 var errorMessage = new ErrorMessageResponse(ErrorCode.BadHeaders)
282 {
283 AdditionalData = headersException.Message,
284 };
285
286 if (headersException.MissingOrMalformedHeaders.HasFlag(HeaderTypes.Accept))
287 return this.StatusCode(HttpStatusCode.NotAcceptable, errorMessage);
288
289 return BadRequest(errorMessage);
290 }
291
302 protected Task<IActionResult> Paginated<TModel>(
303 Func<Task<PaginatableResult<TModel>>> queryGenerator,
304 Func<TModel, Task> resultTransformer,
305 int? pageQuery,
306 int? pageSizeQuery,
307 CancellationToken cancellationToken) => PaginatedImpl(
308 queryGenerator,
309 resultTransformer,
310 pageQuery,
311 pageSizeQuery,
312 cancellationToken);
313
325 protected Task<IActionResult> Paginated<TModel, TApiModel>(
326 Func<Task<PaginatableResult<TModel>>> queryGenerator,
327 Func<TApiModel, Task> resultTransformer,
328 int? pageQuery,
329 int? pageSizeQuery,
330 CancellationToken cancellationToken)
331 where TModel : IApiTransformable<TApiModel>
332 => PaginatedImpl(
333 queryGenerator,
334 resultTransformer,
335 pageQuery,
336 pageSizeQuery,
337 cancellationToken);
338
350 async Task<IActionResult> PaginatedImpl<TModel, TResultModel>(
351 Func<Task<PaginatableResult<TModel>>> queryGenerator,
352 Func<TResultModel, Task> resultTransformer,
353 int? pageQuery,
354 int? pageSizeQuery,
355 CancellationToken cancellationToken)
356 {
357 ArgumentNullException.ThrowIfNull(queryGenerator);
358
359 if (pageQuery <= 0 || pageSizeQuery <= 0)
360 return BadRequest(new ErrorMessageResponse(ErrorCode.ApiInvalidPageOrPageSize));
361
362 var pageSize = pageSizeQuery ?? DefaultPageSize;
363 if (pageSize > MaximumPageSize)
364 return BadRequest(new ErrorMessageResponse(ErrorCode.ApiPageTooLarge)
365 {
366 AdditionalData = $"Maximum page size: {MaximumPageSize}",
367 });
368
369 var page = pageQuery ?? 1;
370
371 var paginationResult = await queryGenerator();
372 if (paginationResult.EarlyOut != null)
373 return paginationResult.EarlyOut;
374
375 var queriedResults = paginationResult
376 .Results
377 .Skip((page - 1) * pageSize)
378 .Take(pageSize);
379
380 int totalResults;
381 List<TModel> pagedResults;
382 if (queriedResults.Provider is IAsyncQueryProvider)
383 {
384 totalResults = await paginationResult.Results.CountAsync(cancellationToken);
385 pagedResults = await queriedResults
386 .ToListAsync(cancellationToken);
387 }
388 else
389 {
390 totalResults = paginationResult.Results.Count();
391 pagedResults = queriedResults.ToList();
392 }
393
394 ICollection<TResultModel> finalResults;
395 if (typeof(TModel) == typeof(TResultModel))
396 finalResults = (List<TResultModel>)(object)pagedResults; // clearly a safe cast
397 else
398 finalResults = pagedResults
400 .Select(x => x.ToApi())
401 .ToList();
402
403 if (resultTransformer != null)
404 foreach (var finalResult in finalResults)
405 await resultTransformer(finalResult);
406
407 var carryTheOne = totalResults % pageSize != 0
408 ? 1
409 : 0;
410 return Json(
412 {
413 Content = finalResults,
414 PageSize = pageSize,
415 TotalPages = (ushort)(totalResults / pageSize) + carryTheOne,
416 TotalItems = totalResults,
417 });
418 }
419 }
420}
Represents the header that must be present for every server request.
Definition: ApiHeaders.cs:22
string? RawUserAgent
The client's raw user agent.
Definition: ApiHeaders.cs:81
bool Compatible()
Checks if the ApiVersion is compatible with Version.
Version ApiVersion
The client's API version.
Definition: ApiHeaders.cs:86
long? InstanceId
The instance EntityId.Id being accessed.
Definition: ApiHeaders.cs:71
Thrown when trying to generate ApiHeaders from Microsoft.AspNetCore.Http.Headers.RequestHeaders fails...
HeaderTypes MissingOrMalformedHeaders
The HeaderTypess that are missing or malformed.
virtual ? long Id
The ID of the entity.
Definition: EntityId.cs:13
Metadata about a server instance.
Definition: Instance.cs:9
Represents an error message returned by the server.
Routes to a server actions.
Definition: Routes.cs:9
const string Jobs
The jobs controller.
Definition: Routes.cs:93
Base Controller for API functions.
ApiHeaders ApiHeaders
The Api.ApiHeaders for the operation.
virtual Task< IActionResult > ValidateRequest(CancellationToken cancellationToken)
Performs validation a request.
const ushort MaximumPageSize
Maximum size of Paginated<TModel> results.
IActionResult HeadersIssue(bool ignoreMissingAuth)
Response for missing/Invalid headers.
new NotFoundObjectResult NotFound()
Generic 404 response.
ApiController(IDatabaseContext databaseContext, IAuthenticationContextFactory authenticationContextFactory, ILogger< ApiController > logger, bool requireHeaders)
Initializes a new instance of the ApiController class.
ObjectResult RequiresPosixSystemIdentity(NotImplementedException ex)
Generic 501 response.
readonly bool requireHeaders
If ApiHeaders are required.
StatusCodeResult StatusCode(HttpStatusCode statusCode)
Strongly type calls to ControllerBase.StatusCode(int).
async Task< IActionResult > PaginatedImpl< TModel, TResultModel >(Func< Task< PaginatableResult< TModel > > > queryGenerator, Func< TResultModel, Task > resultTransformer, int? pageQuery, int? pageSizeQuery, CancellationToken cancellationToken)
Generates a paginated response.
const ushort DefaultPageSize
Default size of Paginated<TModel> results.
Task< IActionResult > Paginated< TModel >(Func< Task< PaginatableResult< TModel > > > queryGenerator, Func< TModel, Task > resultTransformer, int? pageQuery, int? pageSizeQuery, CancellationToken cancellationToken)
Generates a paginated response.
Task< IActionResult > Paginated< TModel, TApiModel >(Func< Task< PaginatableResult< TModel > > > queryGenerator, Func< TApiModel, Task > resultTransformer, int? pageQuery, int? pageSizeQuery, CancellationToken cancellationToken)
Generates a paginated response.
ObjectResult RateLimit(RateLimitExceededException rateLimitException)
429 response for a given rateLimitException .
ObjectResult Created(object payload)
Generic 201 response with a given payload .
ILogger< ApiController > Logger
The ILogger for the ApiController.
override async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
Helper for returning paginated models.
Backend abstract implementation of IDatabaseContext.
Represents an Api.Models.Instance in the database.
Definition: Instance.cs:11
Instance Instance
The Models.Instance the InstancePermissionSet belongs to.
InstancePermissionSet InstancePermissionSet
The User's effective Models.InstancePermissionSet if applicable.
Helpers for manipulating the Serilog.Context.LogContext.
const string InstanceIdContextProperty
The Serilog.Context.LogContext property name for Models.Instance Api.Models.EntityId....
const string UserIdContextProperty
The Serilog.Context.LogContext property name for Models.Instance Api.Models.EntityId....
const string RequestPathContextProperty
The Serilog.Context.LogContext property name for Models.User Api.Models.EntityId.Ids.
Represents a host-side model that may be transformed into a TApiModel .
IAuthenticationContext CurrentAuthenticationContext
The IAuthenticationContext the IAuthenticationContextFactory created.
Represents the currently authenticated Models.User.
ErrorCode
Types of Response.ErrorMessageResponses that the API may return.
Definition: ErrorCode.cs:11
HeaderTypes
Types of individual ApiHeaders.
Definition: HeaderTypes.cs:10