Skip to content

Support OpenAPI polymorphic output with JsonDerivedType#16144

Merged
bergmania merged 1 commit intov14/devfrom
v14/feature/attribute-based-openapi-polymorphism
Apr 25, 2024
Merged

Support OpenAPI polymorphic output with JsonDerivedType#16144
bergmania merged 1 commit intov14/devfrom
v14/feature/attribute-based-openapi-polymorphism

Conversation

@kjac
Copy link
Contributor

@kjac kjac commented Apr 24, 2024

Prerequisites

  • I have added steps to test this contribution in the description below

Description

At the moment it's only possible to create a polymorphic output from the Management API by basing the return models on an interface.

This is a little too strict, so we need another way of opting into polymorphic output - by means of class annotation. Enter JsonDerivedType.

This PR allows you to apply JsonDerivedType to a return model base class. The Management API will pick up on the annotations automatically.

When using annotation, it is important that you explicitly specify the type discriminator in JsonDerivedType - like this:

[JsonDerivedType(typeof(MyItem1), nameof(MyItem1))]
[JsonDerivedType(typeof(MyItem2), nameof(MyItem2))]
public abstract class MyItemBase(string value)
{
    public Guid Id { get; } = Guid.NewGuid();

    public string Value { get; set; } = value;
}

public class MyItem1(string value) : MyItemBase(value)
{
    public string MyThing1 => "Hello thing";
}

public class MyItem2(string value) : MyItemBase(value)
{
    public int MyValue2 => 1234;
}

Full code sample below.

Testing this PR

  1. Verify that current polymorphic outputs (based on interfaces) still work - i.e. get user group by key.
  2. Using the code sample below, verify that the custom API has a polymorphic output and that the schema applies the provided type discriminators in the $type property of the API output (note that you will have to authenticate Swagger against the backoffice as the custom API is protected by user auth):
    image
    image

Code sample

using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;
using Umbraco.Cms.Api.Common.Attributes;
using Umbraco.Cms.Api.Common.ViewModels.Pagination;
using Umbraco.Cms.Api.Management.Controllers;
using Umbraco.Cms.Api.Management.OpenApi;
using Umbraco.Cms.Api.Management.Routing;
using Umbraco.Cms.Core.Composing;

namespace Umbraco.Cms.Web.UI.Custom;

[JsonDerivedType(typeof(MyItem1), nameof(MyItem1))]
[JsonDerivedType(typeof(MyItem2), nameof(MyItem2))]
public abstract class MyItemBase(string value)
{
    public Guid Id { get; } = Guid.NewGuid();

    public string Value { get; set; } = value;
}

public class MyItem1(string value) : MyItemBase(value)
{
    public string MyThing1 => "Hello thing";
}

public class MyItem2(string value) : MyItemBase(value)
{
    public int MyValue2 => 1234;
}

[VersionedApiBackOfficeRoute("my/api")]
[ApiExplorerSettings(GroupName = "My API")]
[MapToApi("my-api")]
public class MyBackOfficeApiController : ManagementApiControllerBase
{
    private static readonly List<MyItemBase> AllItems = Enumerable.Range(1, 100)
        .Select(i => i % 2 == 0 ? new MyItem1($"My Item #{i}") : (MyItemBase)new MyItem2($"My Item #{i}"))
        .ToList();

    [HttpGet("item")]
    [ProducesResponseType<PagedViewModel<MyItemBase>>(StatusCodes.Status200OK)]
    public IActionResult GetAllItems(int skip = 0, int take = 10)
        => Ok(
            new PagedViewModel<MyItemBase>
            {
                Items = AllItems.Skip(skip).Take(take),
                Total = AllItems.Count
            }
        );

    [HttpGet("item/{id:guid}")]
    [ProducesResponseType<MyItemBase>(StatusCodes.Status200OK)]
    [ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
    public IActionResult GetItem(Guid id)
    {
        var item = AllItems.FirstOrDefault(item => item.Id == id);

        return item is not null
            ? Ok(item)
            : OperationStatusResult(
                MyApiStatus.NotFound,
                builder => NotFound(
                    builder
                        .WithTitle("That thing wasn't there")
                        .WithDetail("Maybe look for something else?")
                        .Build()
                )
            );
    }

    [HttpPost("item")]
    [ProducesResponseType(StatusCodes.Status201Created)]
    [ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
    public IActionResult CreateItem(string value)
    {
        if (value.StartsWith("New") is false)
        {
            return OperationStatusResult(
                MyApiStatus.InvalidValue,
                builder => BadRequest(
                    builder
                        .WithTitle("That was invalid")
                        .Build()
                )
            );
        }

        if (AllItems.Any(item => item.Value.InvariantEquals(value)))
        {
            return OperationStatusResult(
                MyApiStatus.DuplicateValue,
                builder => BadRequest(
                    builder
                        .WithTitle("You have been DUPED")
                        .Build()
                )
            );
        }

        var newItem = new MyItem1(value);
        AllItems.Add(newItem);
        return CreatedAtId<MyBackOfficeApiController>(
            ctrl => nameof(ctrl.GetItem),
            newItem.Id
        );
    }

    [HttpPut("item/{id:guid}")]
    [ProducesResponseType(StatusCodes.Status200OK)]
    [ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
    [ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
    public IActionResult UpdateItem(Guid id, string value)
    {
        var item = AllItems.FirstOrDefault(item => item.Id == id);
        if (item is null)
        {
            return OperationStatusResult(
                MyApiStatus.NotFound,
                builder => NotFound(
                    builder
                        .WithTitle("That thing wasn't there")
                        .WithDetail("Maybe look for something else?")
                        .Build()
                )
            );
        }

        if (AllItems.Any(i => i.Value.InvariantEquals(value)))
        {
            return OperationStatusResult(
                MyApiStatus.DuplicateValue,
                builder => BadRequest(
                    builder
                        .WithTitle("You have been DUPED")
                        .Build()
                )
            );
        }

        item.Value = value;
        return Ok();
    }

    [HttpDelete("item/{id:guid}")]
    [ProducesResponseType(StatusCodes.Status200OK)]
    [ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
    public IActionResult DeleteItem(Guid id)
    {
        var item = AllItems.FirstOrDefault(item => item.Id == id);

        if (item is null)
        {
            return OperationStatusResult(
                MyApiStatus.NotFound,
                builder => NotFound(
                    builder
                        .WithTitle("That thing wasn't there")
                        .WithDetail("Maybe look for something else?")
                        .Build()
                )
            );
        }

        AllItems.Remove(item);
        return Ok();
    }
}

public enum MyApiStatus
{
    NotFound,
    InvalidValue,
    DuplicateValue
}

public class MyBackOfficeApiComposer : IComposer
{
    public void Compose(IUmbracoBuilder builder)
    {
        builder.Services.ConfigureOptions<MyBackOfficeApiSwaggerGenOptions>();
    }
}

public class MyBackOfficeApiSwaggerGenOptions : IConfigureOptions<SwaggerGenOptions>
{
    public void Configure(SwaggerGenOptions options)
    {
        options.SwaggerDoc("my-api", new OpenApiInfo { Title = "My API", Version = "1.0" });
        options.OperationFilter<MyBackOfficeApiV1OperationSecurityFilter>();
    }
}

public class MyBackOfficeApiV1OperationSecurityFilter : BackOfficeSecurityRequirementsOperationFilterBase
{
    protected override string ApiName => "my-api";
}

@bergmania bergmania merged commit c495836 into v14/dev Apr 25, 2024
@bergmania bergmania deleted the v14/feature/attribute-based-openapi-polymorphism branch April 25, 2024 06:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants