Привет! Меня зовут Андрей, я iOS-разработчик приложения «Пункт Ozon». С помощью него сотрудники пунктов выдачи Ozon выдают посылки, принимают возвраты, проводят инвентаризации. 

Мы хотим упростить работу с OpenAPI-спецификациями, внедрив кодогенерацию для автоматического создания кода на Swift из YAML- или JSON-файла спецификации. Это позволяет автоматизировать создание DTO, сделать их единообразными и повысить эффективность разработки. Кодогенератор также упрощает сетевой слой, генерируя методы API, наборы параметров, заголовки и т.д.

На WWDC `23 Apple представила свой Open Source-генератор Swift OpenAPI Generator, который как раз и решает эту задачу. Давайте рассмотрим, готов ли в текущем виде Swift OpenAPI Generator для решения нашей проблемы.

Какую проблему хотим решить с OpenAPI Generator в целом?

DTO, он же Data Transfer Object, мы создаём, чтобы передать сериализованные данные, полученные от бэкенда, в другие части приложения. Обычно алгоритм такой:

  1. Находим в Swagger нужный нам метод;

  2. Смотрим структуру моделей запроса и ответа;

  3. Создаём DTO, придумывая ему имя;

  4. Создаем Input — входящие данные для запроса, придумывая ему имя;

  5. Создаем Output — полученные данные при выполнении запроса, придумывая ему имя.

enum ChatsDTO {
  struct Input: Encodable {
    let startDate: Date
    let endDate: Date
  }

  struct Output: Decodable {
    struct Chat: Decodable {
      let id: Int
      let unreadCount: Int
      let lastMessage: String
    }

    @LossyArray
    private(set) var chats: [Chat]
  }
}

Такой подход имеет ряд проблем. Давайте рассмотрим их

  • Рутинная работа. Необходимо найти модель, придумать название, правильно перенести;

  • Переиспользование. Бэкенд создает модели и может переиспользовать их в различных запросах. В нашем же подходе при разработке мобильного приложения так просто переиспользовать модели не получается, потому что чаще всего одинаковые модели находятся в разных DTO;

  • Имена. Если нескольким разработчикам сразу нужны одинаковые DTO, им нужно синхронизироваться и создавать DTO заранее, чтобы не было различий в имени и реализации;

  • Актуальность. Нет возможности автоматически обновлять наши DTO при обновлении спецификации, нужно вручную сравнивать спецификацию и реализацию;

  • Если мы хотим найти конкретную реализацию определенного запроса из Swagger среди наших DTO, нужно несколько раз сопоставлять имена, что, согласитесь, неудобно и отнимает время.

Но есть и плюс:

  • при реализации DTO мы выбираем только те поля, которые собираемся использовать. Это помогает уменьшить количество ошибок сериализации при изменении контракта бэкендом.

А вот реализация методов для обращения к API имеет проблему дублирования — приходится несколько раз реализовывать один и тот же метод в разных классах.

Для решения этих проблем попробуем внедрить Swift OpenAPI Generator. Генератор позволит сохранить структуру, заведенную бэкендом, сохранить названия и автоматически соберёт Swift-код, удобный нам. Также это позволит иметь единую реализацию методов API, которую можно использовать в разных частях приложения.

Что из себя представляет Apple Swift OpenAPI Generator

Swift OpenAPI Generator — это кодогенератор, позволяющий по входящей OpenAPI-спецификации генерировать готовый сетевой клиент, включающий реализацию методов API, моделей входных и выходных данных. Также он имеет возможность сгенерировать реализацию сервера для такого API, используя Swift и фреймворк Vapor. Проект разделён на несколько подпроектов: генератор и runtime. Документация достаточно скудна, но для внедрения этого инструмента, на первый взгляд, хватает. Стоит отметить, что Swift OpenAPI Generator требовал iOS 15.0 для работы, но затем с обновлением минимальная версия iOS была понижена до 13.0. Это ещё один случай улучшения обратной совместимости от Apple, как было с async/await. 

Генерация основывается на mustache-шаблонах, в которых указываем структуру сгенерированного файла. Просмотрев исходный код генератора и рантайма, обнаружил первую проблему генератора — невозможность повлиять на процесс генерации. Например,

  • Нельзя сделать декодирование массивов lossy. То есть, если один из элементов массива не был сериализован и бросил ошибку, весь массив данных не будет сериализован;

  • Client генерируется только в стиле async/await, нельзя его сгенерировать в стиле «до async/await»;

  • Нельзя скорректировать шаблоны генерации;

  • Нельзя удалить неиспользуемые поля или методы. Если контракт с бэкендом по каким-то причинам поменялся, наличие в коде неиспользуемых полей повышает вероятность ошибок сериализации. К тому же, лишний код попадает в сборку приложения, увеличивая ее размер;

  • Не поддерживаются схемы, в которых есть ссылки на сторонние JSON-спецификации.

Внедрим генератор в тестовый проект

Swift OpenAPI Generator можно наиболее удобно внедрить в проект несколькими способами:

  • Как SPM Plugin;

  • Как отдельный target.

Эти способы очень хорошо укладываются в модульную архитектуру по правилу «одна спецификация — один модуль». Какие в этом плюсы?

  • У каждого модуля имеется собственное пространство имён;

  • Модуль импортируется «по требованию»;

  • Модули становятся более изолированными и независимыми.

Рассмотрим подключение как SPM plugin, потому что, на мой взгляд, это наиболее простой способ, решающий нашу задачу. Для тестирования воспользуемся публичным API: https://petstore.swagger.io/. Здесь можно получить Swagger-спецификацию, которую можно конвертировать в OpenAPI-спецификацию. Использовать будем Xcode 14.3.1, а OpenAPI Generator версии 0.1.10.

Спецификация
openapi: 3.0.1
info:
  title: Swagger Petstore
  description: 'This is a sample server Petstore server.  You can find out more about
    Swagger at [http://swagger.io](http://swagger.io) or on [irc.freenode.net, #swagger](http://swagger.io/irc/).  For
    this sample, you can use the api key `special-key` to test the authorization filters.'
  termsOfService: http://swagger.io/terms/
  contact:
    email: apiteam@swagger.io
  license:
    name: Apache 2.0
    url: http://www.apache.org/licenses/LICENSE-2.0.html
  version: 1.0.6
externalDocs:
  description: Find out more about Swagger
  url: http://swagger.io
servers:
- url: https://petstore.swagger.io/v2
- url: http://petstore.swagger.io/v2
tags:
- name: pet
  description: Everything about your Pets
  externalDocs:
    description: Find out more
    url: http://swagger.io
- name: store
  description: Access to Petstore orders
- name: user
  description: Operations about user
  externalDocs:
    description: Find out more about our store
    url: http://swagger.io
paths:
  /pet/{petId}/uploadImage:
    post:
      tags:
      - pet
      summary: uploads an image
      operationId: uploadFile
      parameters:
      - name: petId
        in: path
        description: ID of pet to update
        required: true
        schema:
          type: integer
          format: int64
      requestBody:
        content:
          multipart/form-data:
            schema:
              properties:
                additionalMetadata:
                  type: string
                  description: Additional data to pass to server
                file:
                  type: string
                  description: file to upload
                  format: binary
      responses:
        200:
          description: successful operation
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiResponse'
      security:
      - petstore_auth:
        - write:pets
        - read:pets
  /pet:
    put:
      tags:
      - pet
      summary: Update an existing pet
      operationId: updatePet
      requestBody:
        description: Pet object that needs to be added to the store
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/Pet'
          application/xml:
            schema:
              $ref: '#/components/schemas/Pet'
        required: true
      responses:
        400:
          description: Invalid ID supplied
          content: {}
        404:
          description: Pet not found
          content: {}
        405:
          description: Validation exception
          content: {}
      security:
      - petstore_auth:
        - write:pets
        - read:pets
      x-codegen-request-body-name: body
    post:
      tags:
      - pet
      summary: Add a new pet to the store
      operationId: addPet
      requestBody:
        description: Pet object that needs to be added to the store
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/Pet'
          application/xml:
            schema:
              $ref: '#/components/schemas/Pet'
        required: true
      responses:
        405:
          description: Invalid input
          content: {}
      security:
      - petstore_auth:
        - write:pets
        - read:pets
      x-codegen-request-body-name: body
  /pet/findByStatus:
    get:
      tags:
      - pet
      summary: Finds Pets by status
      description: Multiple status values can be provided with comma separated strings
      operationId: findPetsByStatus
      parameters:
      - name: status
        in: query
        description: Status values that need to be considered for filter
        required: true
        style: form
        explode: true
        schema:
          type: array
          items:
            type: string
            default: available
            enum:
            - available
            - pending
            - sold
      responses:
        200:
          description: successful operation
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Pet'
            application/xml:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Pet'
        400:
          description: Invalid status value
          content: {}
      security:
      - petstore_auth:
        - write:pets
        - read:pets
  /pet/findByTags:
    get:
      tags:
      - pet
      summary: Finds Pets by tags
      description: Multiple tags can be provided with comma separated strings. Use
        tag1, tag2, tag3 for testing.
      operationId: findPetsByTags
      parameters:
      - name: tags
        in: query
        description: Tags to filter by
        required: true
        style: form
        explode: true
        schema:
          type: array
          items:
            type: string
      responses:
        200:
          description: successful operation
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Pet'
            application/xml:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Pet'
        400:
          description: Invalid tag value
          content: {}
      deprecated: true
      security:
      - petstore_auth:
        - write:pets
        - read:pets
  /pet/{petId}:
    get:
      tags:
      - pet
      summary: Find pet by ID
      description: Returns a single pet
      operationId: getPetById
      parameters:
      - name: petId
        in: path
        description: ID of pet to return
        required: true
        schema:
          type: integer
          format: int64
      responses:
        200:
          description: successful operation
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Pet'
            application/xml:
              schema:
                $ref: '#/components/schemas/Pet'
        400:
          description: Invalid ID supplied
          content: {}
        404:
          description: Pet not found
          content: {}
      security:
      - api_key: []
    post:
      tags:
      - pet
      summary: Updates a pet in the store with form data
      operationId: updatePetWithForm
      parameters:
      - name: petId
        in: path
        description: ID of pet that needs to be updated
        required: true
        schema:
          type: integer
          format: int64
      requestBody:
        content:
          application/x-www-form-urlencoded:
            schema:
              properties:
                name:
                  type: string
                  description: Updated name of the pet
                status:
                  type: string
                  description: Updated status of the pet
      responses:
        405:
          description: Invalid input
          content: {}
      security:
      - petstore_auth:
        - write:pets
        - read:pets
    delete:
      tags:
      - pet
      summary: Deletes a pet
      operationId: deletePet
      parameters:
      - name: api_key
        in: header
        schema:
          type: string
      - name: petId
        in: path
        description: Pet id to delete
        required: true
        schema:
          type: integer
          format: int64
      responses:
        400:
          description: Invalid ID supplied
          content: {}
        404:
          description: Pet not found
          content: {}
      security:
      - petstore_auth:
        - write:pets
        - read:pets
  /store/order:
    post:
      tags:
      - store
      summary: Place an order for a pet
      operationId: placeOrder
      requestBody:
        description: order placed for purchasing the pet
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/Order'
        required: true
      responses:
        200:
          description: successful operation
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Order'
            application/xml:
              schema:
                $ref: '#/components/schemas/Order'
        400:
          description: Invalid Order
          content: {}
      x-codegen-request-body-name: body
  /store/order/{orderId}:
    get:
      tags:
      - store
      summary: Find purchase order by ID
      description: For valid response try integer IDs with value >= 1 and <= 10. Other
        values will generated exceptions
      operationId: getOrderById
      parameters:
      - name: orderId
        in: path
        description: ID of pet that needs to be fetched
        required: true
        schema:
          maximum: 10
          minimum: 1
          type: integer
          format: int64
      responses:
        200:
          description: successful operation
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Order'
            application/xml:
              schema:
                $ref: '#/components/schemas/Order'
        400:
          description: Invalid ID supplied
          content: {}
        404:
          description: Order not found
          content: {}
    delete:
      tags:
      - store
      summary: Delete purchase order by ID
      description: For valid response try integer IDs with positive integer value.
        Negative or non-integer values will generate API errors
      operationId: deleteOrder
      parameters:
      - name: orderId
        in: path
        description: ID of the order that needs to be deleted
        required: true
        schema:
          minimum: 1
          type: integer
          format: int64
      responses:
        400:
          description: Invalid ID supplied
          content: {}
        404:
          description: Order not found
          content: {}
  /store/inventory:
    get:
      tags:
      - store
      summary: Returns pet inventories by status
      description: Returns a map of status codes to quantities
      operationId: getInventory
      responses:
        200:
          description: successful operation
          content:
            application/json:
              schema:
                type: object
                additionalProperties:
                  type: integer
                  format: int32
      security:
      - api_key: []
  /user/createWithArray:
    post:
      tags:
      - user
      summary: Creates list of users with given input array
      operationId: createUsersWithArrayInput
      requestBody:
        description: List of user object
        content:
          application/json:
            schema:
              type: array
              items:
                $ref: '#/components/schemas/User'
        required: true
      responses:
        default:
          description: successful operation
          content: {}
      x-codegen-request-body-name: body
  /user/createWithList:
    post:
      tags:
      - user
      summary: Creates list of users with given input array
      operationId: createUsersWithListInput
      requestBody:
        description: List of user object
        content:
          application/json:
            schema:
              type: array
              items:
                $ref: '#/components/schemas/User'
        required: true
      responses:
        default:
          description: successful operation
          content: {}
      x-codegen-request-body-name: body
  /user/{username}:
    get:
      tags:
      - user
      summary: Get user by user name
      operationId: getUserByName
      parameters:
      - name: username
        in: path
        description: 'The name that needs to be fetched. Use user1 for testing. '
        required: true
        schema:
          type: string
      responses:
        200:
          description: successful operation
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/User'
            application/xml:
              schema:
                $ref: '#/components/schemas/User'
        400:
          description: Invalid username supplied
          content: {}
        404:
          description: User not found
          content: {}
    put:
      tags:
      - user
      summary: Updated user
      description: This can only be done by the logged in user.
      operationId: updateUser
      parameters:
      - name: username
        in: path
        description: name that need to be updated
        required: true
        schema:
          type: string
      requestBody:
        description: Updated user object
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/User'
        required: true
      responses:
        400:
          description: Invalid user supplied
          content: {}
        404:
          description: User not found
          content: {}
      x-codegen-request-body-name: body
    delete:
      tags:
      - user
      summary: Delete user
      description: This can only be done by the logged in user.
      operationId: deleteUser
      parameters:
      - name: username
        in: path
        description: The name that needs to be deleted
        required: true
        schema:
          type: string
      responses:
        400:
          description: Invalid username supplied
          content: {}
        404:
          description: User not found
          content: {}
  /user/login:
    get:
      tags:
      - user
      summary: Logs user into the system
      operationId: loginUser
      parameters:
      - name: username
        in: query
        description: The user name for login
        required: true
        schema:
          type: string
      - name: password
        in: query
        description: The password for login in clear text
        required: true
        schema:
          type: string
      responses:
        200:
          description: successful operation
          headers:
            X-Rate-Limit:
              description: calls per hour allowed by the user
              schema:
                type: integer
                format: int32
            X-Expires-After:
              description: date in UTC when token expires
              schema:
                type: string
                format: date-time
          content:
            application/json:
              schema:
                type: string
            application/xml:
              schema:
                type: string
        400:
          description: Invalid username/password supplied
          content: {}
  /user/logout:
    get:
      tags:
      - user
      summary: Logs out current logged in user session
      operationId: logoutUser
      responses:
        default:
          description: successful operation
          content: {}
  /user:
    post:
      tags:
      - user
      summary: Create user
      description: This can only be done by the logged in user.
      operationId: createUser
      requestBody:
        description: Created user object
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/User'
        required: true
      responses:
        default:
          description: successful operation
          content: {}
      x-codegen-request-body-name: body
components:
  schemas:
    ApiResponse:
      type: object
      properties:
        code:
          type: integer
          format: int32
        type:
          type: string
        message:
          type: string
    Category:
      type: object
      properties:
        id:
          type: integer
          format: int64
        name:
          type: string
      xml:
        name: Category
    Pet:
      required:
      - photoUrls
      type: object
      properties:
        id:
          type: integer
          format: int64
        category:
          $ref: '#/components/schemas/Category'
        name:
          type: string
          example: doggie
        photoUrls:
          type: array
          xml:
            wrapped: true
          items:
            type: string
            xml:
              name: photoUrl
        tags:
          type: array
          xml:
            wrapped: true
          items:
            $ref: '#/components/schemas/Tag'
        status:
          type: string
          description: pet status in the store
          enum:
          - available
          - pending
          - sold
      xml:
        name: Pet
    Tag:
      type: object
      properties:
        id:
          type: integer
          format: int64
        name:
          type: string
      xml:
        name: Tag
    Order:
      type: object
      properties:
        id:
          type: integer
          format: int64
        petId:
          type: integer
          format: int64
        quantity:
          type: integer
          format: int32
        shipDate:
          type: string
          format: date-time
        status:
          type: string
          description: Order Status
          enum:
          - placed
          - approved
          - delivered
        complete:
          type: boolean
      xml:
        name: Order
    User:
      type: object
      properties:
        id:
          type: integer
          format: int64
        username:
          type: string
        firstName:
          type: string
        lastName:
          type: string
        email:
          type: string
        password:
          type: string
        phone:
          type: string
        userStatus:
          type: integer
          description: User Status
          format: int32
      xml:
        name: User
  securitySchemes:
    api_key:
      type: apiKey
      name: api_key
      in: header
    petstore_auth:
      type: oauth2
      flows:
        implicit:
          authorizationUrl: https://petstore.swagger.io/oauth/authorize
          scopes:
            read:pets: read your pets
            write:pets: modify pets in your account

Давайте начнем внедрять генератор в наш пустой проект. Для этого создадим SPM-пакет и подключим необходимые зависимости, следуя документации:

// swift-tools-version: 5.8
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
  name: "PetsModule",
  platforms:[
    .iOS(.v14),
  ],
  products:[
    .library(name: "PetsModule", targets: ["PetsModule"]),
  ],
  dependencies:[
    .package(url: "https://github.com/apple/swift-openapi-generator", .upToNextMinor(from: "0.1.0")),
    .package(url: "https://github.com/apple/swift-openapi-runtime", .upToNextMinor(from: "0.1.0")),
  ],
  targets: [
    .target(
      name: "PetsModule",
      dependencies:[
        .product(name: "OpenAPIRuntime", package: "swift-openapi-runtime"),
      ],
      plugins:[
        .plugin(name: "OpenAPIGenerator", package: "swift-openapi-generator"),
      ]
    ),
  ]
)

Для работы плагина необходимо использовать две зависимости:

  • OpenAPIRuntime. Он включает в себя Core-логику и необходим для работы генератора.

  • OpenAPIGenerator. Собственно, сам генератор.

Подключим плагин с помощью.plugin, просто указав название пакета. 

Теперь нам надо подготовить входные данные. Плагин требует два YAML-файла: файл спецификации, который должен называться openapi.yaml, и файл конфигурации, который должен называться openapi-generator-config. Файл спецификации был подготовлен выше. Давайте подготовим файл конфигурации.

Файл конфигурации выглядит таким образом:

generate:
 - types
 - client

В нём мы указываем, какие сущности нам нужно сгенерировать:

  • Types активирует генерацию DTO;

  • Client активирует генерацию абстрактного сетевого слоя, который будет использовать сгенерированные DTO;

  • Server генерирует готовую реализацию сервера по спецификации API.

Нам нужны именно types и client.

Таким образом, наш модуль будет выглядеть так:

Давайте соберем наш пустой проект, в котором будет подключен PetsModule. Сборка такого проекта заняла 105 секунд при использовании MacBook Pro M1, Xcode 14.3.1. Это вторая проблема нового генератора — очень долгое время сборки зависимостей. На схеме изображён таймлайн процессов сборки. Из основного:

  • Сборка компонентов генератора «с нуля» занимает 1.5 минуты;

  • Генерация моделей по спецификации занимает 5,7 секунды;

  • При замене спецификации пересборка компонентов генератора не происходит. Время тратится непосредственно на генерацию.

Например, генерация моделей по YAML размером в 18 КБ занимает примерно 5 секунд, из которых 3 секунды компилировались сгенерированные файлы. Если подать на вход генератору файл размером в 109 КБ, генерация займет уже около 26 секунд, из которых 20 секунд занимает компиляция сгенерированных файлов.

Итак, генерация и компиляция произошли. Здесь проявляется третья проблема генератора. Генератор не поддерживает спецификации, которые позволяют работать с разными content type, и даёт предупреждение об этом:

warning: Feature "Multiple content types" is not supported

Также обратите внимание, что multipart/form-data тоже не поддерживается.

Вернёмся к результатам генерации. Какие файлы мы получили? Мы получаем файлы:

  • Client.swift. В нём сгенерирован абстрактный клиент для работы с API;

  • Server.swift. В нём сгенерирован сервер для выдачи результатов по нашему API. В нашем случае он пуст, т.к. Server мы не генерировали;

  • Types.swift. В нём сгенерированы модели для создания запросов на сервер и сериализации ответов.

Эти файлы расположены в Derived Data по такому пути:

/Users/{username}/Library/Developer/Xcode/DerivedData/{projectName}/SourcePackages/plugins/petsmodule.output/PetsModule/OpenAPIGenerator/GeneratedSources/

Это четвёртая проблема генератора. Получение доступа к ним из Xcode может вызвать сложности, т.к. по ⌘+⇧+O к ним не добраться, а по ⌘+клик Xcode может и не найти. С другой стороны, файлы не лежат в проекте и не создают merge conflict.

Итак, рассмотрим файл Types.swift. Какова его структура?

В этой схеме изображена структура полученного файла. В APIProtocol объявлены API-методы, их входные данные и выходные. Затем в разделе Servers перечислены все базовые серверы, которые были указаны в спецификации. В разделе Components реализованы все модели-компоненты. В Operations — каждый метод и соответствующий ему Input и Output.

Теперь перейдем к реализации Client.swift.

В файле реализована структура Client, которая основывается на универсальном HTTP-клиенте, транспортный слой которого вы должны реализовать сами, например, на основе стандартной URLSession. Также есть поддержка так называемых Middleware — возможности отлавливать запросы и корректировать их при необходимости, например, добавлять авторизационный токен. Также с помощью Middleware можно легко выдавать Mock-ответы на запросы, что пригодится при тестировании

Реализация методов API довольно типовая и выглядит так:

  /// Find pet by ID
  ///
  /// Returns a single pet
  ///
  /// - Remark: HTTP `GET /pet/{petId}`.
  /// - Remark: Generated from `#/paths//pet/{petId}/get(getPetById)`.
  public func getPetById(_ input: Operations.getPetById.Input) async throws
    -> Operations.getPetById.Output
  {
    try await client.send(
      input: input,
      forOperation: Operations.getPetById.id,
      serializer: { input in
        let path = try converter.renderedRequestPath(
          template: "/pet/{}",
          parameters: [input.path.petId]
        )
        var request: OpenAPIRuntime.Request = .init(path: path, method: .get)
        suppressMutabilityWarning(&request)
        try converter.setHeaderFieldAsText(
          in: &request.headerFields,
          name: "accept",
          value: "application/json"
        )
        return request
      },
      deserializer: { response in
        switch response.statusCode {
        case 200:
          let headers: Operations.getPetById.Output.Ok.Headers = .init()
          let contentType = converter.extractContentTypeIfPresent(
            in: response.headerFields
          )
          let body: Operations.getPetById.Output.Ok.Body
          if try contentType == nil
            || converter.isMatchingContentType(
              received: contentType,
              expectedRaw: "application/json"
            )
          {
            body = try converter.getResponseBodyAsJSON(
              Components.Schemas.Pet.self,
              from: response.body,
              transforming: { value in .json(value) }
            )
          } else {
            throw converter.makeUnexpectedContentTypeError(contentType: contentType)
          }
          return .ok(.init(headers: headers, body: body))
        case 400:
          let headers: Operations.getPetById.Output.BadRequest.Headers = .init()
          return .badRequest(.init(headers: headers, body: nil))
        case 404:
          let headers: Operations.getPetById.Output.NotFound.Headers = .init()
          return .notFound(.init(headers: headers, body: nil))
        default: return .undocumented(statusCode: response.statusCode, .init())
        }
      }
    )
  }

Все реализовано с помощью async/await.

Как применять на практике

Для начала создадим недостающие компоненты для работы сгенерированного клиента в демонстрационных целях:

import Foundation
import OpenAPIRuntime
import PetsModule

final class NetworkClientFactory {
  private init() { }
  static func makePetsClient() -> PetsModule.Client {
    PetsModule.Client(
      serverURL: try! PetsModule.Servers.server1(),
      configuration: Configuration(dateTranscoder: .iso8601),
      transport: NetworkClientTransport(),
      middlewares: [NetworkClientMiddleware()]
    )
  }
}

private final class NetworkClientTransport: ClientTransport {
  private let session = URLSession.shared
  func send(_ request: OpenAPIRuntime.Request, baseURL: URL, operationID: String) async throws -> OpenAPIRuntime.Response {
    var urlString = baseURL.absoluteString + request.path
    if let query = request.query {
      urlString += "?\(query)"
    }
    let newUrl = URL(string: urlString)!
    var urlRequest = URLRequest(url: newUrl)
    urlRequest.httpMethod = request.method.rawValue
    urlRequest.httpBody = request.body
    urlRequest.allHTTPHeaderFields = Dictionary(uniqueKeysWithValues: request.headerFields.map { (key: $0.name, value: $0.value) })
    let (bodyData, urlResponse) = try await session.data(for: urlRequest)
    let code = (urlResponse as? HTTPURLResponse)?.statusCode ?? 0
    let headers = (urlResponse as? HTTPURLResponse)?.allHeaderFields.map { HeaderField(name: $0.key as! String, value: $0.value as! String)} ?? []
    return OpenAPIRuntime.Response(statusCode: code, headerFields: headers, body: bodyData)
  }
}

private final class NetworkClientMiddleware: ClientMiddleware {
  func intercept(_ request: OpenAPIRuntime.Request, baseURL: URL, operationID: String, next: @Sendable (OpenAPIRuntime.Request, URL) async throws -> OpenAPIRuntime.Response) async throws -> OpenAPIRuntime.Response {
    print("Intercepting: \(request), baseURL: \(baseURL)")
    return try await next(request, baseURL)
  }
}

Таким образом, мы подготовили минимально необходимый набор классов, который включает создание запроса, отправку запроса, создание модели ответа и перехват запроса с помощью Middleware. Также создана фабрика клиентов. Рассмотрим всю систему в целом.

Если рассмотреть код детальнее, то видно, что для выполнения запроса на сервере нам нужно подготовить Input, который состоит из query, body, headers и другие параметров, и обработать Output таким образом, каким нам нужно. Обратите внимание, что все сущности строго типизированы, а это позволяет писать более безопасный код.

Время подводить итоги

Использование кодогенератора для клиента API по спецификации может существенно сократить время разработки и избавить от ряда проблем и ошибок. Это помогает уменьшить количество рутины и ускорить разработку, так как основной код для общения с сервером будет сгенерирован по единым правилам.

Рассмотрим преимущества этого генератора:

  • Генерирует DTO строго по спецификации, количество ошибок разработчиков уменьшается, уменьшается количество самописного кода;

  • Создаёт готовый клиент для обращений к API;

  • Имеет механизм, позволяющий добавлять любые заголовки: авторизационные заголовки, какую-то дополнительную информацию (протокол ClientMiddleware);

  • Есть возможность кастомизировать декодирование дат (протокол DateTranscoder);

  • Можно организовать модульную архитектуру.

Однако он имеет и ряд недостатков:

  • Нет поддержки Lossy Array. Для нас поддержка Lossy Array — это важно, т.к. позволяет сохранять работоспособность приложения при невалидных данных;

  • Нет поддержки multipart/form-data. Для нас необходимо поддерживать такой content-type, поэтому его отсутствие является минусом;

  • Нет поддержки внешних спецификаций (JSON). Это тоже важный момент, т.к. для упрощения спецификаций есть возможность добавлять ссылки на другие спецификации, а генератор не может это обработать;

  • Нет возможности скорректировать шаблоны генерации. Мы бы могли добавить property wrapper, который добавляет Lossy Decoding, другие обертки, но не имеем возможности повлиять на генерацию.

Таким образом, считаю, что продукт пока не готов для того, чтобы мы встроили его в наш проект.

Отвечая на вопрос «готов ли OpenAPI Generator для продуктивного кода?», скажу, что нет, продукт еще сыроват и стоит дождаться дальнейших обновлений. Однако если у вас несколько разных API, вы свободно можете несколько из них перевести на кодогенерацию. Наиболее хорошо инструмент себя проявит тогда, когда вам надо собрать MVP какого-то проекта или функциональности, которая нужна либо для демонстрации, либо для какого-то теста. Если этот инструмент вам не подходит, предлагаю рассмотреть альтернативу — https://openapi-generator.tech/. Этот генератор расширяемый, менее требователен к окружению, а также не требует добавления лишних зависимостей в проект. Мы в дальнейшем тоже рассмотрим возможность его внедрения в наш проект.

Комментарии (4)


  1. zaazza
    25.10.2023 09:24

    Круто

    Lossy array ведь можно без враппера сделать через тип напрямую `array: MyLossyArrayType<DTO>`


    1. haron1020 Автор
      25.10.2023 09:24

      Да, но научить генератор так делать мы не можем без изменения самого генератора.


  1. rezdm
    25.10.2023 09:24

    В итоге речь про production или productive?


    1. haron1020 Автор
      25.10.2023 09:24

      Речь про production, да