Dan Stevens
Dan Stevens

Reputation: 6830

Writing OpenAPI schema and generating client code for HTTP API that responds with polymorphic array

I'm trying to write an OpenAPI 3.0 schema for an HTTP API. One of its requests responds with a polymorphic array of objects something like this:

[
  {
    "type": "Base",
    "properties": {
      "baseProp1": "Alpha",
      "baseProp2": "Bravo",
      "baseProp3": "Charlie"
    }
  },
  {
    "type": "Derived",
    "properties": {
      "baseProp1": "Delta",
      "baseProp2": "Echo",
      "baseProp3": "Foxtrot",
      "derivedPropA": "Golf"
    }
  }
]

In other words, the response can be an array of Base objects and/or Derived objects, which is derived from Base. The exact names and properties are different to the actual API in question, but convention for representing object inheritance is the same.

I have a number of questions related to this:

  1. I notice that the properties for the objects are wrapped in a "properties" property. Is the example above a JSON compliant way for an HTTP API to serialize polymorphic arrays?
  2. Will a typical API code generator be able to deserialize this correctly? The particular code generator I'm using is Visual Studio's 2022 build-in OpenAPI 'connected service' generator (essentially NSwag). Ideally I would want the generated classes for Base and Derived not to expose the fact that the JSON they were deserialized from had their properties wrapped in "properties" (i.e. BaseProp1, BaseProp2, etc. are defined on the class itself`)
  3. Assuming my code generator can accommodate this, is there a particular way I must define the response in the OpenAPI schema for it to do this correctly?

Preferably, I'd like to use the generated C# client code as-is, but I'm open to extending it (via partial class) if need be.

I admit, I could (and will) do more research around the technologies involved, in particular Newtonsoft Json.NET used in client code, but I wanted to submit the question first.

Upvotes: 1

Views: 6448

Answers (1)

Dan Stevens
Dan Stevens

Reputation: 6830

Update as of 2nd Feb

Following further investigation and experimentation, I've made additional changes to the solution:

Type discriminator

The Swagger/OpenAPI 3.0 spec supports a feature related to inheritance and polymorphism whereby a property on a object can be used to discriminate its subtype. In the case of the getAll operation, this can be defined as follows in the schema:

...
paths:
  /getAll:
    get:
      operationId: getAll
      responses:
        '200':
          description: Gets all objects
          content:
            application/json:
              schema:
                type: array
                items:
                  oneOf:
                    - $ref: '#/components/schemas/BaseResponse'
                    - $ref: '#/components/schemas/DerivedResponse'
                discriminator:
                  propertyName: type
                  mapping:
                    Base: '#/components/schemas/BaseResponse'
                    Derived: '#/components/schemas/DerivedResponse'
...
components:
  schemas:
    BaseResponse:
      type: object
      description: An item in the array type response for getAll operation
      properties:
        type:
          $ref: '#/components/schemas/ObjectType'
        properties:
          $ref: '#/components/schemas/Base'
    DerivedResponse:
      allOf:
        - $ref: '#/components/schemas/BaseResponse'
      properties:
        properties:
          $ref: '#/components/schemas/Derived'

Note that I've replaced the GetAllResponseItem scheme with two new schemes BaseResponse and DerivedResponse. See also branch attempt3 in the GitHub repository.

Removed interfaces IBase and IDerived and added BaseResponse.Object virtual property

The IBase and IDerived interfaces aren't needed so I removed them.

In order to accomodate the two new response types BaseResponse and DerivedResponse, I created a new virtual get property Object of type Base along with an override on the Derived class, both of which return the value of the Properties property. This is to bridge the BaseResponse.Properties and DerivedResponse.Properties, since the latter property hides the former, such that we can access the correct Properties property of a DerivedResponse object when accessing its Derived object via a BaseResponse type. This following code should illustrate:

var derivedResponse = new DerivedResponse
{
    Type = ObjectType.Derived,
    Properties = new Derived()
};
BaseResponse derivedResponseViaBase = (BaseResponse)derivedResponse;

// BaseResponse.Properties is null because it's a different property to
// DerivedResponse.Properties
Assert.IsNull(derivedResponseViaBase.Properties);

// Normally, you would have to cast the `BaseResponse` back to
// `DerivedResponse` to get the correct `Derived` object
Assert.That((DerivedResponse)derivedResponseViaBase.Properties,
  Is.SameAs(derivedResponse.Object))

// Instead, we can use the BaseResponse.Object which, as a virtual
// method, will always provide the correct `Base` or subtype thereof
Assert.That(derivedResponseViaBase.Object, Is.SameAs(derivedResponse.Object));
Assert.That(derivedResponseViaBase.Object, Is.TypeOf<Derived>);

See also branch attempt4 in the GitHub repository

Original answer

After some time researching and experimenting, here are my answers to my own questions:

  1. I notice that the properties for the objects are wrapped in a "properties" property. Is the example above a JSON compliant way for an HTTP API to serialize polymorphic arrays?

In terms of JSON convention(s) for include type data with an object, I struggled to find a definitive one - it appears to be up to the developer what convention to use.

  1. Will a typical API code generator be able to deserialize this correctly? The particular code generator I'm using is Visual Studio's 2022 build-in OpenAPI 'connected service' generator (essentially NSwag). Ideally I would want the generated classes for Base and Derived not to expose the fact that the JSON they were deserialized from had their properties wrapped in "properties" (i.e. BaseProp1, BaseProp2, etc. are defined on the class itself)

There might be a way to instruct a JSON parser interpret an object in the form { "type": [Type], "properties": { ... } as a single object (rather than two nested ones), but I've not found a way to do this automatically with NSwag or Newtonsoft.

There is a way to do this in the Swagger/Open API 3.0 spec. See my update above and Inheritance and Polymorphism section of the Swagger specification. When using NSwag to generate a C# client, a JsonSubtypes converter is still needed, to inform the JSON deserializer of the type relationship.

  1. Assuming my code generator can accommodate this, is there a particular way I must define the response in the OpenAPI schema for it to do this correctly?

Yes, I've found a way to do this in a way that works how I want. It does involve some customisations to generated client code. The way I solved this in the end is as follows.

Creating API schema

The first step was to create an OpenAPI/Swagger schema that defines the following:

  • A schema named Base of type object
  • A schema named Derived of type object that derives from Base
  • A schema named GetAllResponseItem of type object that wraps Base objects and their derivatives
  • A schema named ObjectType of type string that is a enum with values Base and Derived.
  • A get operation with the path getAll that returns an array of GetAllResponseItem objects

Here is schema for this, written in YAML.

openapi: 3.0.0
info:
  title: My OpenAPI/Swagger schema for StackOverflow question #70791679
  version: 1.0.0
paths:
  /getAll:
    get:
      operationId: getAll
      responses:
        '200':
          description: Gets all objects
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/GetAllResponseItem'
components:
  schemas:
    Base:
      type: object
      description: Base type
      properties:
        baseProp1:
          type: string
          example: Alpha
        baseProp2:
          type: string
          example: Bravo
        baseProp3:
          type: string
          example: Charlie
    Derived:
      type: object
      description: Derived type that extends Base
      allOf:
        - $ref: '#/components/schemas/Base'
      properties:
        derivedPropA:
          type: string
          example: Golf
    GetAllResponseItem:
      type: object
      description: An item in the array type response for getAll operation
      properties:
        type:
          $ref: '#/components/schemas/ObjectType'
        properties:
          $ref: '#/components/schemas/Base'
    ObjectType:
      type: string
      description: Discriminates the type of object (e.g. Base, Derived) the item is holding
      enum:
        - Base
        - Derived

Creating the C# client

The next step was to create a C# schema in Visual Studio. I did this by creating a C# Class Library project and adding an OpenAPI connected service using the above file as a schema. Doing so created generated a code file that defined the following partial classes:

  • MyApiClient
  • Base
  • Derived (inherits Base)
  • GetAllResponseItem (with a Type property of type ObjectType and a Properties property of type Base)
  • ObjectType (an enum with items Base and Derived)
  • ApiException (not important for this discussion)

Next I installed the JsonSubtypes nuget package. This will allow us to instruct the JSON deserializer in the API client, when it is expecting a Base object, to instead provide a Derived object when the JSON has the DerivedPropA property.

Next, I add the following code file that extends the generated API code:

MyApiClient.cs:

using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using JsonSubTypes;
using Newtonsoft.Json;

namespace MyApi
{
    public interface IBase
    {
        string BaseProp1 { get; set; }
        string BaseProp2 { get; set; }
        string BaseProp3 { get; set; }
    }
    public interface IDerived : IBase
    {
        string DerivedPropA { get; set; }
    }

    public interface IMyApiClient
    {
        Task<ICollection<IBase>> GetAllAsync(CancellationToken cancellationToken = default);
    }

    // Use a JsonConverter provided by JsonSubtypes, which deserializes a Base object as a Derived
    // subtype when it contains a property named 'DerivedPropA'
    [JsonConverter(typeof(JsonSubtypes))]
    [JsonSubtypes.KnownSubTypeWithProperty(typeof(Derived), nameof(Derived.DerivedPropA))]
    public partial class Base : IBase {}

    public partial class Derived : IDerived {}

    public partial class MyApiClient : IMyApiClient
    {
        async Task<ICollection<IBase>> IMyApiClient.GetAllAsync(CancellationToken cancellationToken)
        {
            var resp = await GetAllAsync(cancellationToken).ConfigureAwait(false);
            return resp.Select(o => (IBase) o.Properties).ToList();
        }
    }
}

The interfaces IBase, IDerived, and IMyApiClient attempt to hide from consumers of IMyApiClient the fact that the actual response from the API uses type ICollection<GetAllResponseItem> and instead provides the type ICollection<IBase>. This isn't perfect since nothing forces the usage of IMyApiClient and GetAllResponseItem class is declared as public. It may be possible to encapsulate this further, but it would probably involve customising the client code generation.

Finally, here's some test code to demonstrate usage:

Tests.cs:

using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
using MyApi;
using NUnit.Framework;

namespace ApiClientTests
{
    public class Tests
    {
        private readonly IBase[] _allObjects = {
            new Base {
                BaseProp1 = "Alpha", BaseProp2 = "Bravo", BaseProp3 = "Charlie"
            },
            new Derived {
                BaseProp1 = "Delta", BaseProp2 = "Echo", BaseProp3 = "Foxtrot",
                DerivedPropA = "Golf"
            }
        };

        [Test]
        public void ShouldBeAbleToAccessPropertiesOnBaseAndDerivedTypes()
        {
            IBase baseObject = _allObjects[0];
            Assert.That(baseObject, Is.TypeOf<Base>());
            Assert.That(baseObject.BaseProp1, Is.EqualTo("Alpha"));

            IDerived derivedObject = (IDerived)_allObjects[1];
            Assert.That(derivedObject, Is.TypeOf<Derived>());
            Assert.That(derivedObject.DerivedPropA, Is.EqualTo("Golf"));
        }

        [Test]
        public void ShouldBeAbleToDiscriminateDerivativeTypesUsingTypeCasting()
        {
            IDerived[] derivatives = _allObjects.OfType<IDerived>().ToArray();
            Assert.That(derivatives.Length, Is.EqualTo(1));
            Assert.That(derivatives[0], Is.SameAs(_allObjects[1]));
        }


        [Ignore("Example usage only - API host doesn't exist")]
        [Test]
        public async Task TestGetAllOperation()
        {
            using var httpClient = new HttpClient();
            IMyApiClient apiClient =
                new MyApiClient("https://example.io/", httpClient);
            var resp = await apiClient.GetAllAsync();
            Assert.That(resp, Is.TypeOf<ICollection<IBase>>());

            IBase[] allObjects = resp.ToArray();
            Assert.That(allObjects.Length, Is.EqualTo(2));
            Assert.That(allObjects[0].BaseProp1, Is.EqualTo("Alpha"));
            Assert.That(((IDerived)allObjects[1]).DerivedPropA, Is.EqualTo("Golf"));
        }
    }
}

The source code is available in GitHub: https://github.com/DanStevens/StackOverflow70791679

I appreciate this may have been a fairly niche question and answer, but writing up the question has really helped me come to the simplest solution (indeed by first attempt was more complex than my second). Perhaps this question might be useful to someone else.

Lastly, the actual project that initiated this question, where I will be applying what I've learnt, is available also on GitHub: https://github.com/DanStevens/BabelNetApiClient

Upvotes: 2

Related Questions