tgstation-server 6.9.2
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.Threading;
7using System.Threading.Tasks;
8
9using Microsoft.AspNetCore.Http;
10using Microsoft.AspNetCore.Mvc;
11using Microsoft.EntityFrameworkCore;
12using Microsoft.EntityFrameworkCore.Query;
13using Microsoft.Extensions.Logging;
14using Microsoft.Net.Http.Headers;
15
16using Octokit;
17
18using Serilog.Context;
19
30
32{
36 public abstract class ApiController : ApiControllerBase
37 {
41 private const ushort DefaultPageSize = 10;
42
46 private const ushort MaximumPageSize = 100;
47
52
57
62
67
71 protected ILogger<ApiController> Logger { get; }
72
76 protected Models.Instance? Instance { get; }
77
81 readonly bool requireHeaders;
82
91 protected ApiController(
92 IDatabaseContext databaseContext,
93 IAuthenticationContext authenticationContext,
94 IApiHeadersProvider apiHeadersProvider,
95 ILogger<ApiController> logger,
96 bool requireHeaders)
97 {
98 DatabaseContext = databaseContext ?? throw new ArgumentNullException(nameof(databaseContext));
99 AuthenticationContext = authenticationContext ?? throw new ArgumentNullException(nameof(authenticationContext));
100 ApiHeadersProvider = apiHeadersProvider ?? throw new ArgumentNullException(nameof(apiHeadersProvider));
101 Logger = logger ?? throw new ArgumentNullException(nameof(logger));
102
104 this.requireHeaders = requireHeaders;
105 }
106
108#pragma warning disable CA1506 // TODO: Decomplexify
109 protected override async ValueTask<IActionResult?> HookExecuteAction(Func<Task> executeAction, CancellationToken cancellationToken)
110 {
111 ArgumentNullException.ThrowIfNull(executeAction);
112
113 // validate the headers
114 if (ApiHeaders == null)
115 {
116 if (requireHeaders)
118 }
119
120 var errorCase = await ValidateRequest(cancellationToken);
121 if (errorCase != null)
122 return errorCase;
123
124 if (ModelState?.IsValid == false)
125 {
126 var errorMessages = ModelState
127 .SelectMany(x => x.Value!.Errors)
128 .Select(x => x.ErrorMessage)
129
130 // We use RequiredAttributes purely for preventing properties from becoming nullable in the databases
131 // We validate missing required fields in controllers
132 // Unfortunately, we can't remove the whole validator for that as it checks other things like StringLength
133 // This is the best way to deal with it unfortunately
134 .Where(x => !x.EndsWith(" field is required.", StringComparison.Ordinal));
135
136 if (errorMessages.Any())
137 return BadRequest(
138 new ErrorMessageResponse(ErrorCode.ModelValidationFailure)
139 {
140 AdditionalData = String.Join(Environment.NewLine, errorMessages),
141 });
142
143 ModelState.Clear();
144 }
145
146 using (ApiHeaders?.InstanceId != null
148 : null)
151 : null)
152 using (LogContext.PushProperty(SerilogContextHelper.RequestPathContextProperty, $"{Request.Method} {Request.Path}"))
153 {
154 if (ApiHeaders != null)
155 {
156 var isGet = HttpMethods.IsGet(Request.Method);
157 Logger.Log(
158 isGet
159 ? LogLevel.Trace
160 : LogLevel.Debug,
161 "Starting API request: Version: {clientApiVersion}. {userAgentHeaderName}: {clientUserAgent}",
162 ApiHeaders.ApiVersion.Semver(),
163 HeaderNames.UserAgent,
165 }
166 else if (Request.Headers.TryGetValue(HeaderNames.UserAgent, out var userAgents))
167 Logger.LogDebug(
168 "Starting unauthorized API request. {userAgentHeaderName}: {allUserAgents}",
169 HeaderNames.UserAgent,
170 userAgents);
171 else
172 Logger.LogDebug(
173 "Starting unauthorized API request. No {userAgentHeaderName}!",
174 HeaderNames.UserAgent);
175
176 await executeAction();
177 }
178
179 return null;
180 }
181#pragma warning restore CA1506
182
187 protected new NotFoundObjectResult NotFound() => NotFound(new ErrorMessageResponse(ErrorCode.ResourceNeverPresent));
188
193 protected new ObjectResult Unauthorized() => this.StatusCode(HttpStatusCode.Unauthorized, null);
194
200 protected ObjectResult RequiresPosixSystemIdentity(NotImplementedException ex)
201 {
202 Logger.LogTrace(ex, "System identities not implemented!");
203 return this.StatusCode(HttpStatusCode.NotImplemented, new ErrorMessageResponse(ErrorCode.RequiresPosixSystemIdentity));
204 }
205
211 protected StatusCodeResult StatusCode(HttpStatusCode statusCode) => StatusCode((int)statusCode);
212
218 protected ObjectResult Created(object payload) => StatusCode((int)HttpStatusCode.Created, payload);
219
225 protected ObjectResult RateLimit(RateLimitExceededException rateLimitException)
226 {
227 ArgumentNullException.ThrowIfNull(rateLimitException);
228
229 Logger.LogWarning(rateLimitException, "Exceeded GitHub rate limit!");
230
231 var secondsString = Math.Ceiling(rateLimitException.GetRetryAfterTimeSpan().TotalSeconds).ToString(CultureInfo.InvariantCulture);
232 Response.Headers.Add(HeaderNames.RetryAfter, secondsString);
233 return this.StatusCode(HttpStatusCode.TooManyRequests, new ErrorMessageResponse(ErrorCode.GitHubApiRateLimit));
234 }
235
241 protected virtual ValueTask<IActionResult?> ValidateRequest(CancellationToken cancellationToken)
242 => ValueTask.FromResult<IActionResult?>(null);
243
249 protected IActionResult HeadersIssue(HeadersException headersException)
250 {
251 if (headersException == null)
252 throw new InvalidOperationException("Expected a header parse exception!");
253
254 var errorMessage = new ErrorMessageResponse(ErrorCode.BadHeaders)
255 {
256 AdditionalData = headersException.Message,
257 };
258
259 if (headersException.ParseErrors.HasFlag(HeaderErrorTypes.Accept))
260 return this.StatusCode(HttpStatusCode.NotAcceptable, errorMessage);
261
262 return BadRequest(errorMessage);
263 }
264
275 protected ValueTask<IActionResult> Paginated<TModel>(
276 Func<ValueTask<PaginatableResult<TModel>>> queryGenerator,
277 Func<TModel, ValueTask>? resultTransformer,
278 int? pageQuery,
279 int? pageSizeQuery,
280 CancellationToken cancellationToken) => PaginatedImpl(
281 queryGenerator,
282 resultTransformer,
283 pageQuery,
284 pageSizeQuery,
285 cancellationToken);
286
298 protected ValueTask<IActionResult> Paginated<TModel, TApiModel>(
299 Func<ValueTask<PaginatableResult<TModel>>> queryGenerator,
300 Func<TApiModel, ValueTask>? resultTransformer,
301 int? pageQuery,
302 int? pageSizeQuery,
303 CancellationToken cancellationToken)
304 where TModel : IApiTransformable<TApiModel>
305 => PaginatedImpl(
306 queryGenerator,
307 resultTransformer,
308 pageQuery,
309 pageSizeQuery,
310 cancellationToken);
311
323 async ValueTask<IActionResult> PaginatedImpl<TModel, TResultModel>(
324 Func<ValueTask<PaginatableResult<TModel>>> queryGenerator,
325 Func<TResultModel, ValueTask>? resultTransformer,
326 int? pageQuery,
327 int? pageSizeQuery,
328 CancellationToken cancellationToken)
329 {
330 ArgumentNullException.ThrowIfNull(queryGenerator);
331
332 if (pageQuery <= 0 || pageSizeQuery <= 0)
333 return BadRequest(new ErrorMessageResponse(ErrorCode.ApiInvalidPageOrPageSize));
334
335 var pageSize = pageSizeQuery ?? DefaultPageSize;
336 if (pageSize > MaximumPageSize)
337 return BadRequest(new ErrorMessageResponse(ErrorCode.ApiPageTooLarge)
338 {
339 AdditionalData = $"Maximum page size: {MaximumPageSize}",
340 });
341
342 var page = pageQuery ?? 1;
343
344 var paginationResult = await queryGenerator();
345 if (!paginationResult.Valid)
346 return paginationResult.EarlyOut;
347
348 var queriedResults = paginationResult
349 .Results
350 .Skip((page - 1) * pageSize)
351 .Take(pageSize);
352
353 int totalResults;
354 List<TModel> pagedResults;
355 if (queriedResults.Provider is IAsyncQueryProvider)
356 {
357 totalResults = await paginationResult.Results.CountAsync(cancellationToken);
358 pagedResults = await queriedResults
359 .ToListAsync(cancellationToken);
360 }
361 else
362 {
363 totalResults = paginationResult.Results.Count();
364 pagedResults = queriedResults.ToList();
365 }
366
367 ICollection<TResultModel> finalResults;
368 if (typeof(TModel) == typeof(TResultModel))
369 finalResults = (List<TResultModel>)(object)pagedResults; // clearly a safe cast
370 else
371 finalResults = pagedResults
373 .Select(x => x.ToApi())
374 .ToList();
375
376 if (resultTransformer != null)
377 foreach (var finalResult in finalResults)
378 await resultTransformer(finalResult);
379
380 var carryTheOne = totalResults % pageSize != 0
381 ? 1
382 : 0;
383 return Json(
385 {
386 Content = finalResults,
387 PageSize = pageSize,
388 TotalPages = (ushort)(totalResults / pageSize) + carryTheOne,
389 TotalItems = totalResults,
390 });
391 }
392 }
393}
Represents the header that must be present for every server request.
Definition ApiHeaders.cs:25
string? RawUserAgent
The client's raw user agent.
Definition ApiHeaders.cs:94
Version ApiVersion
The client's API version.
Definition ApiHeaders.cs:99
long? InstanceId
The instance EntityId.Id being accessed.
Definition ApiHeaders.cs:84
Thrown when trying to generate ApiHeaders from Microsoft.AspNetCore.Http.Headers.RequestHeaders fails...
HeaderErrorTypes ParseErrors
The HeaderErrorTypess 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.
Base class for all API style controllers.
Base Controller for API functions.
async ValueTask< IActionResult > PaginatedImpl< TModel, TResultModel >(Func< ValueTask< PaginatableResult< TModel > > > queryGenerator, Func< TResultModel, ValueTask >? resultTransformer, int? pageQuery, int? pageSizeQuery, CancellationToken cancellationToken)
Generates a paginated response.
const ushort MaximumPageSize
Maximum size of Paginated<TModel> results.
new NotFoundObjectResult NotFound()
Generic 404 response.
ObjectResult RequiresPosixSystemIdentity(NotImplementedException ex)
Generic 501 response.
IActionResult HeadersIssue(HeadersException headersException)
Response for missing/Invalid headers.
override async ValueTask< IActionResult?> HookExecuteAction(Func< Task > executeAction, CancellationToken cancellationToken)
Hook for executing a request.A ValueTask<TResult> resulting in an IActionResult that,...
new ObjectResult Unauthorized()
Generic 401 response.
readonly bool requireHeaders
If ApiHeaders are required.
StatusCodeResult StatusCode(HttpStatusCode statusCode)
Strongly type calls to ControllerBase.StatusCode(int).
virtual ValueTask< IActionResult?> ValidateRequest(CancellationToken cancellationToken)
Performs validation a request.
const ushort DefaultPageSize
Default size of Paginated<TModel> results.
ValueTask< IActionResult > Paginated< TModel >(Func< ValueTask< PaginatableResult< TModel > > > queryGenerator, Func< TModel, ValueTask >? 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.
ApiController(IDatabaseContext databaseContext, IAuthenticationContext authenticationContext, IApiHeadersProvider apiHeadersProvider, ILogger< ApiController > logger, bool requireHeaders)
Initializes a new instance of the ApiController class.
ValueTask< IActionResult > Paginated< TModel, TApiModel >(Func< ValueTask< PaginatableResult< TModel > > > queryGenerator, Func< TApiModel, ValueTask >? resultTransformer, int? pageQuery, int? pageSizeQuery, CancellationToken cancellationToken)
Generates a paginated response.
Backend abstract implementation of IDatabaseContext.
Instance? Instance
The Models.Instance the InstancePermissionSet belongs to.
InstancePermissionSet? InstancePermissionSet
The User's effective Models.InstancePermissionSet if applicable.
bool Valid
If the IAuthenticationContext is for a valid login.
ApiHeaders? ApiHeaders
The created Api.ApiHeaders, if any.
HeadersException? HeadersException
The Api.HeadersException thrown when attempting to parse the ApiHeaders if any.
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 .
Represents the currently authenticated Models.User.
ErrorCode
Types of Response.ErrorMessageResponses that the API may return.
Definition ErrorCode.cs:12
HeaderErrorTypes
Types of individual ApiHeaders errors.