为响应多态数组的 HTTP API 编写 OpenAPI 模式并生成客户端代码

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

我正在尝试为 HTTP API 编写 OpenAPI 3.0 架构。它的一个请求以对象的多态数组响应,如下所示:

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

换句话说,响应可以是 Base 个对象 and/or Derived 个对象的数组,它派生自 Base。确切的名称和属性与所讨论的实际 API 不同,但表示对象继承的约定是相同的。

我有很多与此相关的问题:

  1. 我注意到对象的属性包含在 "properties" 属性 中。上面的示例是 HTTP API 序列化多态数组的 JSON 兼容方式吗?
  2. 典型的 API 代码生成器能否正确反序列化?我使用的特定代码生成器是 Visual Studio's 2022 build-in OpenAPI 'connected service' 生成器(本质上是 NSwag)。理想情况下,我希望为 BaseDerived 生成的 classes 不暴露这样一个事实,即它们被反序列化的 JSON 的属性包含在 "properties" 中(即 BaseProp1BaseProp2 等是在 class 本身上定义的`)
  3. 假设我的代码生成器可以解决这个问题,是否有一种特定的方法我必须在 OpenAPI 架构中定义响应才能正确执行此操作?

最好,我想按原样使用生成的 C# 客户端代码,但如果需要,我愿意扩展它(通过部分 class)。

我承认,我可以(并且将会)围绕所涉及的技术做更多的研究,特别是客户端代码中使用的 Newtonsoft Json.NET,但我想先提交问题。

2 月 2 日更新

经过进一步调查和实验,我对解决方案进行了其他更改:

类型鉴别器

Swagger/OpenAPI 3.0 规范支持与 inheritance and polymorphism 相关的功能,即对象上的 属性 可用于区分其子类型。在 getAll 操作的情况下,这可以在架构中定义如下:

...
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'

请注意,我已将 GetAllResponseItem 方案替换为两个新方案 BaseResponseDerivedResponse。另请参阅 GitHub 存储库中的分支 attempt3

删除了接口 IBaseIDerived 并添加了 BaseResponse.Object 虚拟 属性

不需要 IBaseIDerived 接口,所以我删除了它们。

为了适应两种新的响应类型BaseResponseDerivedResponse,我创建了一个新的虚拟get 属性 Object类型BaseDerived class 上覆盖了 return Properties 属性 的值。这是为了桥接 BaseResponse.PropertiesDerivedResponse.Properties,因为后者 属性 隐藏了前者,这样我们就可以访问 Properties 属性 的正确 DerivedResponse 对象,当通过 BaseResponse 类型访问其 Derived 对象时。以下代码应说明:

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

另请参阅 GitHub 存储库中的分支 attempt4

原回答

经过一段时间的研究和试验,以下是我对自己问题的回答:

  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?

就对象包含类型数据的 JSON 约定而言,我努力寻找一个明确的约定 - 它似乎是 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)

可能有一种方法可以指示 JSON 解析器将 { "type": [Type], "properties": { ... } 形式的对象解释为单个对象(而不是两个嵌套的对象),但是我'我还没有找到使用 NSwag 或 Newtonsoft 自动执行此操作的方法。

在 Swagger/Open API 3.0 规范中有一种方法可以做到这一点。请参阅我上面的更新,仍然需要 Inheritance and Polymorphism section of the Swagger specification. When using NSwag to generate a C# client, a JsonSubtypes 转换器,以通知 JSON 反序列化器类型关系。

  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?

是的,我找到了一种以我想要的方式执行此操作的方法。它确实涉及对生成的客户端代码的一些定制。最后我的解决方法如下

正在创建 API 架构

第一步是创建一个 OpenAPI/Swagger 定义以下内容的模式:

  • 名为 Base 的模式 object
  • 一个名为 Derived 的模式 object 派生自 Base
  • 一个名为 GetAllResponseItemobject 类型的架构,它包装了 Base 个对象及其派生对象
  • 名为 ObjectType 且类型为 string 的架构,它是一个具有值 BaseDerived 的枚举。
  • 具有路径 getAllget 操作 return 是 GetAllResponseItem 个对象的数组

这是用 YAML 编写的架构。

openapi: 3.0.0
info:
  title: My OpenAPI/Swagger schema for Whosebug 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

正在创建 C# 客户端

下一步是在 Visual Studio 中创建 C# 架构。我通过创建一个 C# Class 库项目和 adding an OpenAPI connected service 使用上述文件作为模式来做到这一点。这样做会生成一个代码文件,该文件定义了以下部分 classes:

  • MyApiClient
  • Base
  • Derived(继承Base
  • GetAllResponseItemType 属性 类型 ObjectTypeProperties 属性 类型 Base
  • ObjectType(包含项目 BaseDerived 的枚举)
  • ApiException(对于本次讨论不重要)

接下来我安装了 JsonSubtypes nuget 包。这将允许我们在 API 客户端中指示 JSON 反序列化器,当它期望 Base 对象时,在 JSON 有 DerivedPropA 属性.

接下来,我添加以下代码文件来扩展生成的 API 代码:

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

接口 IBaseIDerivedIMyApiClient 试图向 IMyApiClient 的消费者隐藏 API 的实际响应使用的事实输入 ICollection<GetAllResponseItem> 并提供类型 ICollection<IBase>。这并不完美,因为没有任何东西强制使用 IMyApiClient 并且 GetAllResponseItem class 被声明为 public。可以进一步封装它,但它可能涉及自定义客户端代码生成。

最后,这里有一些测试代码来演示用法:

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

源代码可在 GitHub: https://github.com/DanStevens/Whosebug70791679

我很欣赏这可能是一个相当小众的问题和答案,但写下这个问题确实帮助我找到了最简单的解决方案(确实是 first attempt was more complex than my second)。也许这个问题对其他人有用。

最后,提出这个问题的实际项目也可以在 GitHub 上找到:https://github.com/DanStevens/BabelNetApiClient