diff --git a/src/Kyoo.Abstractions/Models/Utils/RequestError.cs b/src/Kyoo.Abstractions/Models/Utils/RequestError.cs new file mode 100644 index 00000000..e37bf63c --- /dev/null +++ b/src/Kyoo.Abstractions/Models/Utils/RequestError.cs @@ -0,0 +1,57 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using System.Linq; +using JetBrains.Annotations; + +namespace Kyoo.Abstractions.Models.Utils +{ + /// + /// The list of errors that where made in the request. + /// + public class RequestError + { + /// + /// The list of errors that where made in the request. + /// + [NotNull] public string[] Errors { get; set; } + + /// + /// Create a new with one error. + /// + /// The error to specify in the response. + public RequestError([NotNull] string error) + { + if (error == null) + throw new ArgumentNullException(nameof(error)); + Errors = new[] { error }; + } + + /// + /// Create a new with multiple errors. + /// + /// The errors to specify in the response. + public RequestError([NotNull] string[] errors) + { + if (errors == null || !errors.Any()) + throw new ArgumentException("Errors must be non null and not empty", nameof(errors)); + Errors = errors; + } + } +} diff --git a/src/Kyoo.Core/CoreModule.cs b/src/Kyoo.Core/CoreModule.cs index fc83caf5..bdae8b38 100644 --- a/src/Kyoo.Core/CoreModule.cs +++ b/src/Kyoo.Core/CoreModule.cs @@ -18,18 +18,21 @@ using System; using System.Collections.Generic; +using System.Linq; using Autofac; using Autofac.Core; using Autofac.Core.Registration; using Autofac.Extras.AttributeMetadata; using Kyoo.Abstractions; using Kyoo.Abstractions.Controllers; +using Kyoo.Abstractions.Models.Utils; using Kyoo.Core.Api; using Kyoo.Core.Controllers; using Kyoo.Core.Models.Options; using Kyoo.Core.Tasks; using Kyoo.Database; using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.StaticFiles; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -139,8 +142,21 @@ namespace Kyoo.Core string publicUrl = _configuration.GetPublicUrl(); services.AddMvcCore() + .AddDataAnnotations() .AddControllersAsServices() - .AddApiExplorer(); + .AddApiExplorer() + .ConfigureApiBehaviorOptions(options => + { + options.SuppressMapClientErrors = true; + options.InvalidModelStateResponseFactory = ctx => + { + string[] errors = ctx.ModelState + .SelectMany(x => x.Value.Errors) + .Select(x => x.ErrorMessage) + .ToArray(); + return new BadRequestObjectResult(new RequestError(errors)); + }; + }); services.AddControllers() .AddNewtonsoftJson(x => { diff --git a/src/Kyoo.Core/Views/CollectionApi.cs b/src/Kyoo.Core/Views/CollectionApi.cs index e491effa..91e9c24e 100644 --- a/src/Kyoo.Core/Views/CollectionApi.cs +++ b/src/Kyoo.Core/Views/CollectionApi.cs @@ -77,7 +77,6 @@ namespace Kyoo.Core.Api /// /// The number of shows to return. /// A page of shows. - /// A page of shows. /// or is invalid. /// No collection with the ID could be found. [HttpGet("{id:int}/shows")] diff --git a/src/Kyoo.Core/Views/Helper/CrudApi.cs b/src/Kyoo.Core/Views/Helper/CrudApi.cs index 4f795f47..ad10bfb9 100644 --- a/src/Kyoo.Core/Views/Helper/CrudApi.cs +++ b/src/Kyoo.Core/Views/Helper/CrudApi.cs @@ -24,6 +24,8 @@ using Kyoo.Abstractions.Controllers; using Kyoo.Abstractions.Models; using Kyoo.Abstractions.Models.Exceptions; using Kyoo.Abstractions.Models.Permissions; +using Kyoo.Abstractions.Models.Utils; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; namespace Kyoo.Core.Api @@ -63,15 +65,38 @@ namespace Kyoo.Core.Api } /// - /// Get a by ID. + /// Construct and return a page from an api. /// + /// The list of resources that should be included in the current page. + /// + /// The max number of items that should be present per page. This should be the same as in the request, + /// it is used to calculate if this is the last page and so on. + /// + /// The type of items on the page. + /// A Page representing the response. + protected Page Page(ICollection resources, int limit) + where TResult : IResource + { + return new Page(resources, + new Uri(BaseURL, Request.Path), + Request.Query.ToDictionary(x => x.Key, x => x.Value.ToString(), StringComparer.InvariantCultureIgnoreCase), + limit); + } + + /// + /// Get by ID + /// + /// + /// Get a specific resource via it's ID. + /// /// The ID of the resource to retrieve. - /// The retrieved . - /// The exist and is returned. - /// A resource with the ID does not exist. + /// The retrieved resource. + /// A resource with the given ID does not exist. [HttpGet("{id:int}")] [PartialPermission(Kind.Read)] - public virtual async Task> Get(int id) + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> Get(int id) { T ret = await _repository.GetOrDefault(id); if (ret == null) @@ -79,9 +104,20 @@ namespace Kyoo.Core.Api return ret; } + /// + /// Get by slug + /// + /// + /// Get a specific resource via it's slug (a unique, human readable identifier). + /// + /// The slug of the resource to retrieve. + /// The retrieved resource. + /// A resource with the given ID does not exist. [HttpGet("{slug}")] [PartialPermission(Kind.Read)] - public virtual async Task> Get(string slug) + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> Get(string slug) { T ret = await _repository.GetOrDefault(slug); if (ret == null) @@ -89,9 +125,20 @@ namespace Kyoo.Core.Api return ret; } + /// + /// Get count + /// + /// + /// Get the number of resources that match the filters. + /// + /// A list of filters to respect. + /// How many resources matched that filter. + /// Invalid filters. [HttpGet("count")] [PartialPermission(Kind.Read)] - public virtual async Task> GetCount([FromQuery] Dictionary where) + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] + public async Task> GetCount([FromQuery] Dictionary where) { try { @@ -99,13 +146,27 @@ namespace Kyoo.Core.Api } catch (ArgumentException ex) { - return BadRequest(new { Error = ex.Message }); + return BadRequest(new RequestError(ex.Message)); } } + /// + /// Get all + /// + /// + /// Get all resources that match the given filter. + /// + /// Sort information about the query (sort by, sort order). + /// Where the pagination should start. + /// Filter the returned items. + /// How many items per page should be returned. + /// A list of resources that match every filters. + /// Invalid filters or sort information. [HttpGet] [PartialPermission(Kind.Read)] - public virtual async Task>> GetAll([FromQuery] string sortBy, + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] + public async Task>> GetAll([FromQuery] string sortBy, [FromQuery] int afterID, [FromQuery] Dictionary where, [FromQuery] int limit = 20) @@ -120,21 +181,25 @@ namespace Kyoo.Core.Api } catch (ArgumentException ex) { - return BadRequest(new { Error = ex.Message }); + return BadRequest(new RequestError(ex.Message)); } } - protected Page Page(ICollection resources, int limit) - where TResult : IResource - { - return new Page(resources, - new Uri(BaseURL, Request.Path), - Request.Query.ToDictionary(x => x.Key, x => x.Value.ToString(), StringComparer.InvariantCultureIgnoreCase), - limit); - } - + /// + /// Create new + /// + /// + /// Create a new item and store it. You may leave the ID unspecified, it will be filed by Kyoo. + /// + /// The resource to create. + /// The created resource. + /// The resource in the request body is invalid. + /// This item already exists (maybe a duplicated slug). [HttpPost] [PartialPermission(Kind.Create)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] + [ProducesResponseType(StatusCodes.Status409Conflict, Type = typeof(ActionResult<>))] public virtual async Task> Create([FromBody] T resource) { try @@ -143,7 +208,7 @@ namespace Kyoo.Core.Api } catch (ArgumentException ex) { - return BadRequest(new { Error = ex.Message }); + return BadRequest(new RequestError(ex.Message)); } catch (DuplicatedItemException) { @@ -152,9 +217,26 @@ namespace Kyoo.Core.Api } } + /// + /// Edit + /// + /// + /// Edit an item. If the ID is specified it will be used to identify the resource. + /// If not, the slug will be used to identify it. + /// + /// The resource to edit. + /// + /// Should old properties of the resource be discarded or should null values considered as not changed? + /// + /// The created resource. + /// The resource in the request body is invalid. + /// No item found with the specified ID (or slug). [HttpPut] [PartialPermission(Kind.Write)] - public virtual async Task> Edit([FromQuery] bool resetOld, [FromBody] T resource) + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> Edit([FromBody] T resource, [FromQuery] bool resetOld = true) { try { @@ -171,37 +253,6 @@ namespace Kyoo.Core.Api } } - [HttpPut("{id:int}")] - [PartialPermission(Kind.Write)] - public virtual async Task> Edit(int id, [FromQuery] bool resetOld, [FromBody] T resource) - { - resource.ID = id; - try - { - return await _repository.Edit(resource, resetOld); - } - catch (ItemNotFoundException) - { - return NotFound(); - } - } - - [HttpPut("{slug}")] - [PartialPermission(Kind.Write)] - public virtual async Task> Edit(string slug, [FromQuery] bool resetOld, [FromBody] T resource) - { - try - { - T old = await _repository.Get(slug); - resource.ID = old.ID; - return await _repository.Edit(resource, resetOld); - } - catch (ItemNotFoundException) - { - return NotFound(); - } - } - [HttpDelete("{id:int}")] [PartialPermission(Kind.Delete)] public virtual async Task Delete(int id) diff --git a/src/Kyoo.Swagger/GenericResponseProvider.cs b/src/Kyoo.Swagger/GenericResponseProvider.cs new file mode 100644 index 00000000..f9ebd37c --- /dev/null +++ b/src/Kyoo.Swagger/GenericResponseProvider.cs @@ -0,0 +1,65 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Kyoo.Utils; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ApplicationModels; + +namespace Kyoo.Swagger +{ + /// + /// A filter that change 's + /// that where set to to the + /// return type of the method. + /// + /// + /// This is only useful when the return type of the method is a generics type and that can't be specified in the + /// attribute directly (since attributes don't support generics). This should not be used otherwise. + /// + public class GenericResponseProvider : IApplicationModelProvider + { + /// + public int Order => -1; + + /// + public void OnProvidersExecuted(ApplicationModelProviderContext context) + { } + + /// + public void OnProvidersExecuting(ApplicationModelProviderContext context) + { + foreach (ActionModel action in context.Result.Controllers.SelectMany(x => x.Actions)) + { + IEnumerable responses = action.Filters + .OfType() + .Where(x => x.Type == typeof(ActionResult<>)); + foreach (ProducesResponseTypeAttribute response in responses) + { + Type type = action.ActionMethod.ReturnType; + type = Utility.GetGenericDefinition(type, typeof(Task<>))?.GetGenericArguments()[0] ?? type; + type = Utility.GetGenericDefinition(type, typeof(ActionResult<>))?.GetGenericArguments()[0] ?? type; + response.Type = type; + } + } + } + } +} diff --git a/src/Kyoo.Swagger/Kyoo.Swagger.csproj b/src/Kyoo.Swagger/Kyoo.Swagger.csproj index 713eef6e..54d4e2f3 100644 --- a/src/Kyoo.Swagger/Kyoo.Swagger.csproj +++ b/src/Kyoo.Swagger/Kyoo.Swagger.csproj @@ -7,8 +7,7 @@ - - + diff --git a/src/Kyoo.Swagger/SwaggerModule.cs b/src/Kyoo.Swagger/SwaggerModule.cs index 9343cc33..8bea787a 100644 --- a/src/Kyoo.Swagger/SwaggerModule.cs +++ b/src/Kyoo.Swagger/SwaggerModule.cs @@ -18,11 +18,12 @@ using System; using System.Collections.Generic; -using System.IO; using Kyoo.Abstractions.Controllers; using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Mvc.ApplicationModels; using Microsoft.Extensions.DependencyInjection; -using Microsoft.OpenApi.Models; +using NSwag; +using NSwag.Generation.AspNetCore; using static Kyoo.Abstractions.Models.Utils.Constants; namespace Kyoo.Swagger @@ -47,44 +48,50 @@ namespace Kyoo.Swagger /// public void Configure(IServiceCollection services) { - services.AddSwaggerGen(options => + services.AddTransient(); + services.AddOpenApiDocument(options => { - options.SwaggerDoc("v1", new OpenApiInfo + options.Title = "Kyoo API"; + // TODO use a real multi-line description in markdown. + options.Description = "The Kyoo's public API"; + options.Version = "1.0.0"; + options.DocumentName = "v1"; + options.UseControllerSummaryAsTagDescription = true; + options.GenerateExamples = true; + options.PostProcess = x => { - Version = "v1", - Title = "Kyoo API", - Description = "The Kyoo's public API", - Contact = new OpenApiContact + x.Info.Contact = new OpenApiContact { Name = "Kyoo's github", - Url = new Uri("https://github.com/AnonymusRaccoon/Kyoo/issues/new/choose") - }, - License = new OpenApiLicense + Url = "https://github.com/AnonymusRaccoon/Kyoo" + }; + x.Info.License = new OpenApiLicense { Name = "GPL-3.0-or-later", - Url = new Uri("https://github.com/AnonymusRaccoon/Kyoo/blob/master/LICENSE") - } + Url = "https://github.com/AnonymusRaccoon/Kyoo/blob/master/LICENSE" + }; + }; + options.AddOperationFilter(x => + { + if (x is AspNetCoreOperationProcessorContext ctx) + return ctx.ApiDescription.ActionDescriptor.AttributeRouteInfo?.Order != AlternativeRoute; + return true; }); - - options.LoadXmlDocumentation(); - options.UseAllOfForInheritance(); - options.SwaggerGeneratorOptions.SortKeySelector = x => x.RelativePath; - options.DocInclusionPredicate((_, apiDescription) - => apiDescription.ActionDescriptor.AttributeRouteInfo?.Order != AlternativeRoute); }); } /// public IEnumerable ConfigureSteps => new IStartupAction[] { - SA.New(app => app.UseSwagger(), SA.Before + 1), - SA.New(app => app.UseSwaggerUI(x => + SA.New(app => app.UseOpenApi(), SA.Before + 1), + SA.New(app => app.UseSwaggerUi3(x => { - x.SwaggerEndpoint("/swagger/v1/swagger.json", "Kyoo v1"); + x.OperationsSorter = "alpha"; + x.TagsSorter = "alpha"; }), SA.Before), SA.New(app => app.UseReDoc(x => { - x.SpecUrl = "/swagger/v1/swagger.json"; + x.Path = "/redoc"; }), SA.Before) }; } diff --git a/src/Kyoo.Swagger/XmlDocumentationLoader.cs b/src/Kyoo.Swagger/XmlDocumentationLoader.cs deleted file mode 100644 index b580e351..00000000 --- a/src/Kyoo.Swagger/XmlDocumentationLoader.cs +++ /dev/null @@ -1,68 +0,0 @@ -// Kyoo - A portable and vast media library solution. -// Copyright (c) Kyoo. -// -// See AUTHORS.md and LICENSE file in the project root for full license information. -// -// Kyoo is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// any later version. -// -// Kyoo is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with Kyoo. If not, see . - -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Xml.Linq; -using System.Xml.XPath; -using Microsoft.Extensions.DependencyInjection; -using Swashbuckle.AspNetCore.SwaggerGen; - -namespace Kyoo.Swagger -{ - /// - /// A static class containing a custom way to include XML to Swagger. - /// - public static class XmlDocumentationLoader - { - /// - /// Inject human-friendly descriptions for Operations, Parameters and Schemas based on XML Comment files - /// - /// The swagger generator to add documentation to. - public static void LoadXmlDocumentation(this SwaggerGenOptions options) - { - ICollection docs = Directory.GetFiles(AppContext.BaseDirectory, "*.xml") - .Select(XDocument.Load) - .ToList(); - Dictionary elements = docs - .SelectMany(x => x.XPathSelectElements("/doc/members/member[@name and not(inheritdoc)]")) - .ToDictionary(x => x.Attribute("name")!.Value, x => x); - - foreach (XElement doc in docs - .SelectMany(x => x.XPathSelectElements("/doc/members/member[inheritdoc[@cref]]"))) - { - if (elements.TryGetValue(doc.Attribute("cref")!.Value, out XElement member)) - doc.Element("inheritdoc")!.ReplaceWith(member); - } - foreach (XElement doc in docs.SelectMany(x => x.XPathSelectElements("//see[@cref]"))) - { - string fullName = doc.Attribute("cref")!.Value; - string shortName = fullName[(fullName.LastIndexOf('.') + 1)..]; - // TODO won't work with fully qualified methods. - if (fullName.StartsWith("M:")) - shortName += "()"; - doc.ReplaceWith(shortName); - } - - foreach (XDocument doc in docs) - options.IncludeXmlComments(() => new XPathDocument(doc.CreateReader()), true); - } - } -}