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);
- }
- }
-}