Привет! Меня зовут Андрей, я 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, мы создаём, чтобы передать сериализованные данные, полученные от бэкенда, в другие части приложения. Обычно алгоритм такой:
Находим в Swagger нужный нам метод;
Смотрим структуру моделей запроса и ответа;
Создаём DTO, придумывая ему имя;
Создаем Input — входящие данные для запроса, придумывая ему имя;
Создаем 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/. Этот генератор расширяемый, менее требователен к окружению, а также не требует добавления лишних зависимостей в проект. Мы в дальнейшем тоже рассмотрим возможность его внедрения в наш проект.