From 25a73bce0f91a307ebcbecc3d4235b86b81ac591 Mon Sep 17 00:00:00 2001 From: Oliver Booth Date: Sat, 2 Mar 2024 03:46:29 +0000 Subject: [PATCH] refactor: add api versioned endpoints --- OliverBooth.Api/ConfigureSwaggerOptions.cs | 40 +++++ .../Controllers/v1/Blog/BlogController.cs | 141 ++++++++++++++++++ .../{ => v2}/Blog/AuthorController.cs | 4 +- .../{ => v2}/Blog/PostController.cs | 4 +- OliverBooth.Api/OliverBooth.Api.csproj | 3 +- OliverBooth.Api/Program.cs | 35 ++++- src/ts/app/API.ts | 2 +- 7 files changed, 220 insertions(+), 9 deletions(-) create mode 100644 OliverBooth.Api/ConfigureSwaggerOptions.cs create mode 100644 OliverBooth.Api/Controllers/v1/Blog/BlogController.cs rename OliverBooth.Api/Controllers/{ => v2}/Blog/AuthorController.cs (95%) rename OliverBooth.Api/Controllers/{ => v2}/Blog/PostController.cs (98%) diff --git a/OliverBooth.Api/ConfigureSwaggerOptions.cs b/OliverBooth.Api/ConfigureSwaggerOptions.cs new file mode 100644 index 0000000..da7a45b --- /dev/null +++ b/OliverBooth.Api/ConfigureSwaggerOptions.cs @@ -0,0 +1,40 @@ +using Asp.Versioning.ApiExplorer; +using Microsoft.Extensions.Options; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace OliverBooth.Api; + +internal sealed class ConfigureSwaggerOptions(IApiVersionDescriptionProvider provider) + : IConfigureNamedOptions +{ + public void Configure(SwaggerGenOptions options) + { + foreach (var description in provider.ApiVersionDescriptions) + { + options.SwaggerDoc(description.GroupName, CreateVersionInfo(description)); + } + } + + public void Configure(string? name, SwaggerGenOptions options) + { + Configure(options); + } + + private OpenApiInfo CreateVersionInfo( + ApiVersionDescription description) + { + var info = new OpenApiInfo + { + Title = "api.oliverbooth.dev", + Version = description.ApiVersion.ToString() + }; + + if (description.IsDeprecated) + { + info.Description += " This API version has been deprecated."; + } + + return info; + } +} diff --git a/OliverBooth.Api/Controllers/v1/Blog/BlogController.cs b/OliverBooth.Api/Controllers/v1/Blog/BlogController.cs new file mode 100644 index 0000000..32a294e --- /dev/null +++ b/OliverBooth.Api/Controllers/v1/Blog/BlogController.cs @@ -0,0 +1,141 @@ +using Asp.Versioning; +using Humanizer; +using Microsoft.AspNetCore.Mvc; +using OliverBooth.Api.Data; +using OliverBooth.Common.Data.Blog; +using OliverBooth.Common.Data.Web.Users; +using OliverBooth.Common.Services; + +namespace OliverBooth.Api.Controllers.v1.Blog; + +[ApiController] +[Route("blog")] +[Produces("application/json")] +[ApiVersion(1)] +[Obsolete("API v1 is deprecated and will be removed in future. Use /v2")] +public sealed class BlogController : ControllerBase +{ + private readonly IBlogPostService _blogPostService; + private readonly IUserService _userService; + + /// + /// Initializes a new instance of the class. + /// + /// The . + /// The . + public BlogController(IBlogPostService blogPostService, IUserService userService) + { + _blogPostService = blogPostService; + _userService = userService; + } + + /// + /// Returns the number of publicly published blog posts. + /// + /// The number of publicly published blog posts. + [HttpGet("count")] + [EndpointDescription("Returns the number of publicly published blog posts.")] + [ProducesResponseType(StatusCodes.Status200OK)] + public IActionResult Count() + { + return Ok(new { count = _blogPostService.GetBlogPostCount() }); + } + + /// + /// Returns a collection of all blog posts on the specified page. + /// + /// The page number. + /// An array of objects. + [HttpGet("posts/{page:int?}")] + [EndpointDescription("Returns a collection of all blog posts on the specified page.")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(BlogPost[]))] + public IActionResult GetAllBlogPosts(int page = 0) + { + const int itemsPerPage = 10; + IReadOnlyList allPosts = _blogPostService.GetBlogPosts(page, itemsPerPage); + return Ok(allPosts.Select(post => CreatePostObject(post))); + } + + /// + /// Returns a collection of all blog posts which contain the specified tag on the specified page. + /// + /// The tag for which to search. + /// The page number. + /// An array of objects. + [HttpGet("posts/tagged/{tag}/{page:int?}")] + [EndpointDescription("Returns a collection of all blog posts which contain the specified tag on the specified page.")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(BlogPost[]))] + public IActionResult GetTaggedBlogPosts(string tag, int page = 0) + { + const int itemsPerPage = 10; + tag = tag.Replace('-', ' ').ToLowerInvariant(); + + IReadOnlyList allPosts = _blogPostService.GetBlogPosts(page, itemsPerPage); + allPosts = allPosts.Where(post => post.Tags.Contains(tag)).ToList(); + return Ok(allPosts.Select(post => CreatePostObject(post))); + } + + /// + /// Returns an object representing the author with the specified ID. + /// + /// The ID of the author. + /// An object representing the author. + [HttpGet("author/{id:guid}")] + [EndpointDescription("Returns an object representing the author with the specified ID.")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(Author))] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public IActionResult GetAuthor(Guid id) + { + if (!_userService.TryGetUser(id, out IUser? author)) return NotFound(); + + return Ok(new + { + id = author.Id, + name = author.DisplayName, + avatarUrl = author.AvatarUrl, + }); + } + + /// + /// Returns an object representing the blog post with the specified ID. + /// + /// The ID of the blog post. + /// An object representing the blog post. + [HttpGet("post/{id:guid?}")] + [EndpointDescription("Returns an object representing the blog post with the specified ID.")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(BlogPost))] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public IActionResult GetPost(Guid id) + { + if (!_blogPostService.TryGetPost(id, out IBlogPost? post)) return NotFound(); + return Ok(CreatePostObject(post, true)); + } + + private object CreatePostObject(IBlogPost post, bool includeContent = false) + { + return new + { + id = post.Id, + commentsEnabled = post.EnableComments, + identifier = post.GetDisqusIdentifier(), + author = post.Author.Id, + title = post.Title, + published = post.Published.ToUnixTimeSeconds(), + updated = post.Updated?.ToUnixTimeSeconds(), + formattedPublishDate = post.Published.ToString("dddd, d MMMM yyyy HH:mm"), + formattedUpdateDate = post.Updated?.ToString("dddd, d MMMM yyyy HH:mm"), + humanizedTimestamp = post.Updated?.Humanize() ?? post.Published.Humanize(), + excerpt = _blogPostService.RenderExcerpt(post, out bool trimmed), + content = includeContent ? _blogPostService.RenderPost(post) : null, + trimmed, + tags = post.Tags.Select(t => t.Replace(' ', '-')), + url = new + { + year = post.Published.ToString("yyyy"), + month = post.Published.ToString("MM"), + day = post.Published.ToString("dd"), + slug = post.Slug + } + }; + } +} diff --git a/OliverBooth.Api/Controllers/Blog/AuthorController.cs b/OliverBooth.Api/Controllers/v2/Blog/AuthorController.cs similarity index 95% rename from OliverBooth.Api/Controllers/Blog/AuthorController.cs rename to OliverBooth.Api/Controllers/v2/Blog/AuthorController.cs index 9c605e2..ac3fbdf 100644 --- a/OliverBooth.Api/Controllers/Blog/AuthorController.cs +++ b/OliverBooth.Api/Controllers/v2/Blog/AuthorController.cs @@ -4,7 +4,7 @@ using OliverBooth.Api.Data; using OliverBooth.Common.Data.Web.Users; using OliverBooth.Common.Services; -namespace OliverBooth.Api.Controllers.Blog; +namespace OliverBooth.Api.Controllers.v2.Blog; /// /// Represents an API controller which allows reading authors of blog posts. @@ -12,7 +12,7 @@ namespace OliverBooth.Api.Controllers.Blog; [ApiController] [Route("v{version:apiVersion}/blog/author")] [Produces("application/json")] -[ApiVersion(1)] +[ApiVersion(2)] public sealed class AuthorController : ControllerBase { private readonly IUserService _userService; diff --git a/OliverBooth.Api/Controllers/Blog/PostController.cs b/OliverBooth.Api/Controllers/v2/Blog/PostController.cs similarity index 98% rename from OliverBooth.Api/Controllers/Blog/PostController.cs rename to OliverBooth.Api/Controllers/v2/Blog/PostController.cs index e6c788b..5e05f23 100644 --- a/OliverBooth.Api/Controllers/Blog/PostController.cs +++ b/OliverBooth.Api/Controllers/v2/Blog/PostController.cs @@ -4,7 +4,7 @@ using OliverBooth.Api.Data; using OliverBooth.Common.Data.Blog; using OliverBooth.Common.Services; -namespace OliverBooth.Api.Controllers.Blog; +namespace OliverBooth.Api.Controllers.v2.Blog; /// /// Represents an API controller which allows reading and writing of blog posts. @@ -12,7 +12,7 @@ namespace OliverBooth.Api.Controllers.Blog; [ApiController] [Route("v{version:apiVersion}/blog/post")] [Produces("application/json")] -[ApiVersion(1)] +[ApiVersion(2)] public sealed class PostController : ControllerBase { private const int ItemsPerPage = 10; diff --git a/OliverBooth.Api/OliverBooth.Api.csproj b/OliverBooth.Api/OliverBooth.Api.csproj index 221585c..a5d3b6a 100644 --- a/OliverBooth.Api/OliverBooth.Api.csproj +++ b/OliverBooth.Api/OliverBooth.Api.csproj @@ -27,8 +27,9 @@ + - + diff --git a/OliverBooth.Api/Program.cs b/OliverBooth.Api/Program.cs index 9394630..44e5189 100644 --- a/OliverBooth.Api/Program.cs +++ b/OliverBooth.Api/Program.cs @@ -1,6 +1,10 @@ using Asp.Versioning; +using Asp.Versioning.ApiExplorer; +using Microsoft.AspNetCore.Mvc.ApiExplorer; +using OliverBooth.Api; using OliverBooth.Common.Extensions; using Serilog; +using Swashbuckle.AspNetCore.SwaggerGen; Log.Logger = new LoggerConfiguration() .WriteTo.Console() @@ -23,17 +27,34 @@ builder.Services.AddCors(options => builder.Services.AddCommonServices(); builder.Services.AddEndpointsApiExplorer(); -builder.Services.AddSwaggerGen(); +builder.Services.AddSwaggerGen(options => options.ResolveConflictingActions(resolver => +{ + foreach (ApiDescription description in resolver) + { + if (description.GetApiVersion()?.MajorVersion == 2) + { + return description; + } + } + + return null; +})); builder.Services.AddControllers(); builder.Services.AddRouting(options => options.LowercaseUrls = true); builder.Services.AddApiVersioning(options => { options.AssumeDefaultVersionWhenUnspecified = true; - options.DefaultApiVersion = new ApiVersion(1); + options.DefaultApiVersion = new ApiVersion(2); options.ReportApiVersions = true; options.ApiVersionReader = new UrlSegmentApiVersionReader(); +}).AddApiExplorer(options => +{ + options.GroupNameFormat = "'v'VVV"; + options.SubstituteApiVersionInUrl = true; }); +builder.Services.ConfigureOptions(); + if (builder.Environment.IsProduction()) { builder.WebHost.AddCertificateFromEnvironment(2844, 5048); @@ -44,7 +65,15 @@ WebApplication app = builder.Build(); if (app.Environment.IsDevelopment()) { app.UseSwagger(); - app.UseSwaggerUI(); + app.UseSwaggerUI(options => + { + var provider = app.Services.GetRequiredService(); + foreach (ApiVersionDescription description in provider.ApiVersionDescriptions) + { + var url = $"/swagger/{description.GroupName}/swagger.json"; + options.SwaggerEndpoint(url, description.GroupName.ToUpperInvariant()); + } + }); } app.UseHttpsRedirection(); diff --git a/src/ts/app/API.ts b/src/ts/app/API.ts index b41cf6a..c00b938 100644 --- a/src/ts/app/API.ts +++ b/src/ts/app/API.ts @@ -3,7 +3,7 @@ import Author from "./Author"; class API { private static readonly BLOG_URL: string = "/blog"; - private static readonly BASE_URL: string = `https://localhost:2840/v1${API.BLOG_URL}`; + private static readonly BASE_URL: string = `https://localhost:2840/v2${API.BLOG_URL}`; private static readonly AUTHOR_URL: string = "/author"; private static readonly POST_URL: string = "/post";