Swagger: Using NSwag instead of Swackbuckles

This commit is contained in:
Zoe Roux 2021-09-20 21:40:56 +02:00
parent e32dcd0f30
commit 561b8e81b2
8 changed files with 273 additions and 147 deletions

View File

@ -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 <https://www.gnu.org/licenses/>.
using System;
using System.Linq;
using JetBrains.Annotations;
namespace Kyoo.Abstractions.Models.Utils
{
/// <summary>
/// The list of errors that where made in the request.
/// </summary>
public class RequestError
{
/// <summary>
/// The list of errors that where made in the request.
/// </summary>
[NotNull] public string[] Errors { get; set; }
/// <summary>
/// Create a new <see cref="RequestError"/> with one error.
/// </summary>
/// <param name="error">The error to specify in the response.</param>
public RequestError([NotNull] string error)
{
if (error == null)
throw new ArgumentNullException(nameof(error));
Errors = new[] { error };
}
/// <summary>
/// Create a new <see cref="RequestError"/> with multiple errors.
/// </summary>
/// <param name="errors">The errors to specify in the response.</param>
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;
}
}
}

View File

@ -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 =>
{

View File

@ -77,7 +77,6 @@ namespace Kyoo.Core.Api
/// </param>
/// <param name="limit">The number of shows to return.</param>
/// <returns>A page of shows.</returns>
/// <response code="200">A page of shows.</response>
/// <response code="400"><paramref name="sortBy"/> or <paramref name="where"/> is invalid.</response>
/// <response code="404">No collection with the ID <paramref name="id"/> could be found.</response>
[HttpGet("{id:int}/shows")]

View File

@ -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
}
/// <summary>
/// Get a <typeparamref name="T"/> by ID.
/// Construct and return a page from an api.
/// </summary>
/// <param name="resources">The list of resources that should be included in the current page.</param>
/// <param name="limit">
/// 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.
/// </param>
/// <typeparam name="TResult">The type of items on the page.</typeparam>
/// <returns>A Page representing the response.</returns>
protected Page<TResult> Page<TResult>(ICollection<TResult> resources, int limit)
where TResult : IResource
{
return new Page<TResult>(resources,
new Uri(BaseURL, Request.Path),
Request.Query.ToDictionary(x => x.Key, x => x.Value.ToString(), StringComparer.InvariantCultureIgnoreCase),
limit);
}
/// <summary>
/// Get by ID
/// </summary>
/// <remarks>
/// Get a specific resource via it's ID.
/// </remarks>
/// <param name="id">The ID of the resource to retrieve.</param>
/// <returns>The retrieved <typeparamref name="T"/>.</returns>
/// <response code="200">The <typeparamref name="T"/> exist and is returned.</response>
/// <response code="404">A resource with the ID <paramref name="id"/> does not exist.</response>
/// <returns>The retrieved resource.</returns>
/// <response code="404">A resource with the given ID does not exist.</response>
[HttpGet("{id:int}")]
[PartialPermission(Kind.Read)]
public virtual async Task<ActionResult<T>> Get(int id)
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<T>> Get(int id)
{
T ret = await _repository.GetOrDefault(id);
if (ret == null)
@ -79,9 +104,20 @@ namespace Kyoo.Core.Api
return ret;
}
/// <summary>
/// Get by slug
/// </summary>
/// <remarks>
/// Get a specific resource via it's slug (a unique, human readable identifier).
/// </remarks>
/// <param name="slug" example="1">The slug of the resource to retrieve.</param>
/// <returns>The retrieved resource.</returns>
/// <response code="404">A resource with the given ID does not exist.</response>
[HttpGet("{slug}")]
[PartialPermission(Kind.Read)]
public virtual async Task<ActionResult<T>> Get(string slug)
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<T>> Get(string slug)
{
T ret = await _repository.GetOrDefault(slug);
if (ret == null)
@ -89,9 +125,20 @@ namespace Kyoo.Core.Api
return ret;
}
/// <summary>
/// Get count
/// </summary>
/// <remarks>
/// Get the number of resources that match the filters.
/// </remarks>
/// <param name="where">A list of filters to respect.</param>
/// <returns>How many resources matched that filter.</returns>
/// <response code="400">Invalid filters.</response>
[HttpGet("count")]
[PartialPermission(Kind.Read)]
public virtual async Task<ActionResult<int>> GetCount([FromQuery] Dictionary<string, string> where)
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
public async Task<ActionResult<int>> GetCount([FromQuery] Dictionary<string, string> 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));
}
}
/// <summary>
/// Get all
/// </summary>
/// <remarks>
/// Get all resources that match the given filter.
/// </remarks>
/// <param name="sortBy">Sort information about the query (sort by, sort order).</param>
/// <param name="afterID">Where the pagination should start.</param>
/// <param name="where">Filter the returned items.</param>
/// <param name="limit">How many items per page should be returned.</param>
/// <returns>A list of resources that match every filters.</returns>
/// <response code="400">Invalid filters or sort information.</response>
[HttpGet]
[PartialPermission(Kind.Read)]
public virtual async Task<ActionResult<Page<T>>> GetAll([FromQuery] string sortBy,
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
public async Task<ActionResult<Page<T>>> GetAll([FromQuery] string sortBy,
[FromQuery] int afterID,
[FromQuery] Dictionary<string, string> 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<TResult> Page<TResult>(ICollection<TResult> resources, int limit)
where TResult : IResource
{
return new Page<TResult>(resources,
new Uri(BaseURL, Request.Path),
Request.Query.ToDictionary(x => x.Key, x => x.Value.ToString(), StringComparer.InvariantCultureIgnoreCase),
limit);
}
/// <summary>
/// Create new
/// </summary>
/// <remarks>
/// Create a new item and store it. You may leave the ID unspecified, it will be filed by Kyoo.
/// </remarks>
/// <param name="resource">The resource to create.</param>
/// <returns>The created resource.</returns>
/// <response code="400">The resource in the request body is invalid.</response>
/// <response code="409">This item already exists (maybe a duplicated slug).</response>
[HttpPost]
[PartialPermission(Kind.Create)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
[ProducesResponseType(StatusCodes.Status409Conflict, Type = typeof(ActionResult<>))]
public virtual async Task<ActionResult<T>> 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
}
}
/// <summary>
/// Edit
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="resource">The resource to edit.</param>
/// <param name="resetOld">
/// Should old properties of the resource be discarded or should null values considered as not changed?
/// </param>
/// <returns>The created resource.</returns>
/// <response code="400">The resource in the request body is invalid.</response>
/// <response code="404">No item found with the specified ID (or slug).</response>
[HttpPut]
[PartialPermission(Kind.Write)]
public virtual async Task<ActionResult<T>> Edit([FromQuery] bool resetOld, [FromBody] T resource)
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<T>> 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<ActionResult<T>> 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<ActionResult<T>> 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<IActionResult> Delete(int id)

View File

@ -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 <https://www.gnu.org/licenses/>.
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
{
/// <summary>
/// A filter that change <see cref="ProducesResponseTypeAttribute"/>'s
/// <see cref="ProducesResponseTypeAttribute.Type"/> that where set to <see cref="ActionResult{T}"/> to the
/// return type of the method.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public class GenericResponseProvider : IApplicationModelProvider
{
/// <inheritdoc />
public int Order => -1;
/// <inheritdoc />
public void OnProvidersExecuted(ApplicationModelProviderContext context)
{ }
/// <inheritdoc />
public void OnProvidersExecuting(ApplicationModelProviderContext context)
{
foreach (ActionModel action in context.Result.Controllers.SelectMany(x => x.Actions))
{
IEnumerable<ProducesResponseTypeAttribute> responses = action.Filters
.OfType<ProducesResponseTypeAttribute>()
.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;
}
}
}
}
}

View File

@ -7,8 +7,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.2.1" />
<PackageReference Include="Swashbuckle.AspNetCore.ReDoc" Version="6.2.1" />
<PackageReference Include="NSwag.AspNetCore" Version="13.13.2" />
<ProjectReference Include="../Kyoo.Abstractions/Kyoo.Abstractions.csproj" />
</ItemGroup>
</Project>

View File

@ -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
/// <inheritdoc />
public void Configure(IServiceCollection services)
{
services.AddSwaggerGen(options =>
services.AddTransient<IApplicationModelProvider, GenericResponseProvider>();
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);
});
}
/// <inheritdoc />
public IEnumerable<IStartupAction> ConfigureSteps => new IStartupAction[]
{
SA.New<IApplicationBuilder>(app => app.UseSwagger(), SA.Before + 1),
SA.New<IApplicationBuilder>(app => app.UseSwaggerUI(x =>
SA.New<IApplicationBuilder>(app => app.UseOpenApi(), SA.Before + 1),
SA.New<IApplicationBuilder>(app => app.UseSwaggerUi3(x =>
{
x.SwaggerEndpoint("/swagger/v1/swagger.json", "Kyoo v1");
x.OperationsSorter = "alpha";
x.TagsSorter = "alpha";
}), SA.Before),
SA.New<IApplicationBuilder>(app => app.UseReDoc(x =>
{
x.SpecUrl = "/swagger/v1/swagger.json";
x.Path = "/redoc";
}), SA.Before)
};
}

View File

@ -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 <https://www.gnu.org/licenses/>.
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
{
/// <summary>
/// A static class containing a custom way to include XML to Swagger.
/// </summary>
public static class XmlDocumentationLoader
{
/// <summary>
/// Inject human-friendly descriptions for Operations, Parameters and Schemas based on XML Comment files
/// </summary>
/// <param name="options">The swagger generator to add documentation to.</param>
public static void LoadXmlDocumentation(this SwaggerGenOptions options)
{
ICollection<XDocument> docs = Directory.GetFiles(AppContext.BaseDirectory, "*.xml")
.Select(XDocument.Load)
.ToList();
Dictionary<string, XElement> 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);
}
}
}