Готов ли Swift OpenAPI Generator для продуктивного кода?

от автора

Привет! Меня зовут Андрей, я 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/. Этот генератор расширяемый, менее требователен к окружению, а также не требует добавления лишних зависимостей в проект. Мы в дальнейшем тоже рассмотрим возможность его внедрения в наш проект.


ссылка на оригинал статьи https://habr.com/ru/articles/769624/