tgstation-server 6.12.3
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 public const ushort DefaultPageSize = 10;
42
46 public 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
194 protected StatusCodeResult StatusCode(HttpStatusCode statusCode) => StatusCode((int)statusCode);
195
201 protected ObjectResult RateLimit(RateLimitExceededException rateLimitException)
202 {
203 ArgumentNullException.ThrowIfNull(rateLimitException);
204
205 Logger.LogWarning(rateLimitException, "Exceeded GitHub rate limit!");
206
207 var secondsString = Math.Ceiling(rateLimitException.GetRetryAfterTimeSpan().TotalSeconds).ToString(CultureInfo.InvariantCulture);
208 Response.Headers.Add(HeaderNames.RetryAfter, secondsString);
209 return this.StatusCode(HttpStatusCode.TooManyRequests, new ErrorMessageResponse(ErrorCode.GitHubApiRateLimit));
210 }
211
217 protected virtual ValueTask<IActionResult?> ValidateRequest(CancellationToken cancellationToken)
218 => ValueTask.FromResult<IActionResult?>(null);
219
225 protected IActionResult HeadersIssue(HeadersException headersException)
226 {
227 if (headersException == null)
228 throw new InvalidOperationException("Expected a header parse exception!");
229
230 var errorMessage = new ErrorMessageResponse(ErrorCode.BadHeaders)
231 {
232 AdditionalData = headersException.Message,
233 };
234
235 if (headersException.ParseErrors.HasFlag(HeaderErrorTypes.Accept))
236 return this.StatusCode(HttpStatusCode.NotAcceptable, errorMessage);
237
238 return BadRequest(errorMessage);
239 }
240
251 protected ValueTask<IActionResult> Paginated<TModel>(
252 Func<ValueTask<PaginatableResult<TModel>>> queryGenerator,
253 Func<TModel, ValueTask>? resultTransformer,
254 int? pageQuery,
255 int? pageSizeQuery,
256 CancellationToken cancellationToken) => PaginatedImpl(
257 queryGenerator,
258 resultTransformer,
259 pageQuery,
260 pageSizeQuery,
261 cancellationToken);
262
274 protected ValueTask<IActionResult> Paginated<TModel, TApiModel>(
275 Func<ValueTask<PaginatableResult<TModel>>> queryGenerator,
276 Func<TApiModel, ValueTask>? resultTransformer,
277 int? pageQuery,
278 int? pageSizeQuery,
279 CancellationToken cancellationToken)
281 => PaginatedImpl(
282 queryGenerator,
283 resultTransformer,
284 pageQuery,
285 pageSizeQuery,
286 cancellationToken);
287
299 async ValueTask<IActionResult> PaginatedImpl<TModel, TResultModel>(
300 Func<ValueTask<PaginatableResult<TModel>>> queryGenerator,
301 Func<TResultModel, ValueTask>? resultTransformer,
302 int? pageQuery,
303 int? pageSizeQuery,
304 CancellationToken cancellationToken)
305 {
306 ArgumentNullException.ThrowIfNull(queryGenerator);
307
308 if (pageQuery <= 0 || pageSizeQuery <= 0)
309 return BadRequest(new ErrorMessageResponse(ErrorCode.ApiInvalidPageOrPageSize));
310
311 var pageSize = pageSizeQuery ?? DefaultPageSize;
312 if (pageSize > MaximumPageSize)
313 return BadRequest(new ErrorMessageResponse(ErrorCode.ApiPageTooLarge)
314 {
315 AdditionalData = $"Maximum page size: {MaximumPageSize}",
316 });
317
318 var page = pageQuery ?? 1;
319
320 var paginationResult = await queryGenerator();
321 if (!paginationResult.Valid)
322 return paginationResult.EarlyOut;
323
324 var queriedResults = paginationResult
325 .Results
326 .Skip((page - 1) * pageSize)
327 .Take(pageSize);
328
329 int totalResults;
330 List<TModel> pagedResults;
331 if (queriedResults.Provider is IAsyncQueryProvider)
332 {
333 totalResults = await paginationResult.Results.CountAsync(cancellationToken);
334 pagedResults = await queriedResults
335 .ToListAsync(cancellationToken);
336 }
337 else
338 {
339 totalResults = paginationResult.Results.Count();
340 pagedResults = [.. queriedResults];
341 }
342
343 ICollection<TResultModel> finalResults;
344 if (typeof(TResultModel).IsAssignableFrom(typeof(TModel)))
345 finalResults = pagedResults.Cast<TResultModel>().ToList(); // clearly a safe cast
346 else
347 finalResults = pagedResults
349 .Select(x => x.ToApi())
350 .ToList();
351
352 if (resultTransformer != null)
353 foreach (var finalResult in finalResults)
354 await resultTransformer(finalResult);
355
356 var carryTheOne = totalResults % pageSize != 0
357 ? 1
358 : 0;
359 return Json(
361 {
362 Content = finalResults,
363 PageSize = pageSize,
364 TotalPages = (ushort)(totalResults / pageSize) + carryTheOne,
365 TotalItems = totalResults,
366 });
367 }
368 }
369}
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.
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,...
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 .
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 .
For creating and accessing authentication contexts.
ErrorCode
Types of Response.ErrorMessageResponses that the API may return.
Definition ErrorCode.cs:12
HeaderErrorTypes
Types of individual ApiHeaders errors.