В процессе работы над проектом потребовалось генерировать аннотации OpenAPI на лету из реализованных в проекте http-хэндлеров. Во-первых, так удобнее взаимодействовать разработчикам frontend и backend. Во-вторых, автоматическая генерация аннотаций позволяет избежать проблем с отставанием документации от реализации, когда мы сначала описали как нужно, начали писать реализацию, она в процессе немного отошла от плана, но доку не обновили. До разработки на Go я много писал проектов на Django и FastAPI. В Django генерация Swagger есть в DRF, в FastAPI генерация Swagger и ReDoc представляется одной из ключевых фич проекта (не кроме крутой асинхронщины, конечно).
Таким образом, задача определилась в виде поиска средства, которое на основании реализованных сейчас и реализуемых в дальнейшем хэнлеров построит файл файл OpenAPI. Отобразить его в понятном и привычном виде меньшая из проблем. При этом к средству есть требования:
минимум затрат ресурсов для написания аннотаций;
минимум затрат ресурсов на поддержание генерируемых документов в актуальном виде;
максимальная автоматизация процесса генерации;
простота использования разработчиками, идеально если при написании логики разработчик не будет даже думать о том, как будет генерироваться дока.
То есть хочется получить FastAPI, но для сервера echo, когда просто запустил сервер, зашел по пути /swagger
, а там уже указаны все параметры для всех твоих эндпоинтов. Забегая вперед скажу, что примерно так и получилось: мы запускаем сервер в тестовом режиме (на проде держать все аннотации так под рукой не очень красиво), вся документация готова. По всем feature-веткам frontend разработчики сразу видят реализованные и измененные эндпоинты, все изменения, попадающие в ветку вносят правки моментально. И никто из разработчиков не описывает руками свои методы. При этом есть определенные ограничения на синтаксис функций обработчиков запросов.
Имеющиеся средства
Перед тем как писать свое решение провели анализ имеющихся бибилиотек для такого рода задач. Было понятно, что мы далеко не первые, у кого возникла подобна потребность. Наиболее популярное решение https://github.com/go-swagger/go-swagger позволяет генерировать документацию, но необходимо аннотировать все обработчики. Это создает дополнительную нагрузку на разработчиков, что скорее просто неприятно, но терпимо. Но больше всего пугает отставание аннотации от реальности. Где-то поменяли параметр, поменяли тип, что-то добавили, убрали. При этом в процессе работы отвлеклись, забыли поменять в аннотации. И вот уже получаем документацию, которой нельзя верить. Хочется, чтобы она создавалась на основании реально используемых параметров.
Находил библиотеку, в которой реализованы обертки для функций методов echo сервера таких как Post, Get, Group и тп. Вместо них был реализован единственный обработчик запросов, регистрировался в echo, а дальше весь роутинг проходил через методы библиотеки. Такого поведения с одной стороны хотелось избежать, с другой пугала необходимость заводить серьезную зависимость от внешней библиотеки, к которой были вопросы по производительности, наличию ошибок. Но подходы вдохновили написать свою легковесную обертку, которая бы только вынула обработчики и их параметры, но максимум функционала оставила бы на echo.
В самом начале реализации была найдена библиотека https://github.com/sashabaranov/go-fastapi, которая была очень похожа на то, что надо. Но она заточена под gorilla, были вопросы к генератору аннотаций.
Общее описание реализации
Генератор аннотаций состоит из компонентов:
генератор аннотаций для структур;
обертка для хэндлеров echo -
GroupWrapper
;ui для отображения результатов.
Такое деление реализовано для возможности использования отдельно генератора аннотаций. То есть, можно взять в свой проект только генератор, с его помощью получать аннотации на структуры, а выстраивать список путей и типов параметров отдельными методами.
Реализованная обертка предлагает один из способов автоматической генерации аннотаций для зарегистрированных в echo путей. Но для этого используется рефлексия, ограничивается синтаксис самих обработчиков запросов под предлагаемый формат. Предложенный способ вызова хэндлеров запросов используется в проекте, показал свое удобство. Однако, он не покрывает все возможности и всю гибкость echo, загоняя разработчика в определенные рамки.
В качестве UI используется стандартный swagger.
Все echo группы оборачиваются в GroupWrapper
, в нем регистрируются хэндлеры и их параметры. Использование обертки групп позволяет получить полный путь до метода, в echo.Echo
нет публичных методов для получения списка зарегистрированных полей и их обработчиков. Есть метод получения списка путей, но он скорее пригодятся для логирования и получения справочной информации. Оборачивая вызов обработчиков удалось реализовать формирование структур параметров хэндлеров.
Генератор аннотаций
Для генерации аннотаций используется рефлексия. Если воспринимать генератор как черный ящик, то видно, что на вход мы подаем структуру, на выходе получаем структуру типа openapi.Parameter
.
Итогом для всего API должна стать структура openapi.Swagger
, в которая состоит из полей:
VendorExtensible
- расширения схемы, по сутиmap[string]interface{}
, в котором можно хранить не описанные стандартом параметры;SwaggerProps
- поля аннотации, описанные согласно стандарту.
На текущем этапе из всех полей нас будут интересовать:
Info
- общая информация для отображения в интерфейсе;Paths
- обязательный параметр, в нем перечисляются все методы API;Definitions
- описание всех структур параметров, как раз заполнением этого поля занимается генератор аннотаций;SecurityDefinitions
- описание способов аутентификации и авторизации.
Процесс генерации происходит по полям передаваемой структуры. На основании reflect.Type
поля выбирается соответствующий тип openapi. Если тип поля - структура, то выполняется рекурсивный вызов генератора для нее. По каждой структуре создается отдельный definition
, поле структуры ссылается на него согласно стандарта openapi.
Регистрация обработчиков
Регистрация обработчиков запросов в echo
выполняется с использованием echo.Group
для группировки по путям, и непосредственно регистрация самих хэндлеров с указанием методов обработки запроса: GET
, POST
и тп.
Пример функции обработки POST запроса в обертке над группой:
func (g *WrapGroup) POST(path string, params generator.HandlerParameters, handler interface{}, m ...echo.MiddlewareFunc) *echo.Route {
if strings.HasSuffix(path, "/") {
path = path[:len(path)-1]
}
// Here we get definitions from handler and add them to routes
handlerInfo, err := processHandler(handler)
fullPath := g.path + path
if err == nil {
routeInfo := generator.RouteInfo{
Method: http.MethodPost,
Handler: handlerInfo,
Tags: g.tags,
Parameters: params,
}
handlerKey := fmt.Sprintf("%s~%s", routeInfo.Method, fullPath)
g.routesHandlers[handlerKey] = routeInfo
}
echoHandler := g.callProcessor(fullPath, handler, true)
return g.echoGroup.POST(path, echoHandler, m...)
}
В данной функции происходит получение параметров вызова переданного третим параметром handler
и формирование информации об обработчике в понятном для генератора аннотации виде. В получаемых из хэндлера данных хранится тип структуры с входящими параметрами, информация и принимаемых на вход файлах, а также тип структуры, который возвращается при успешной обработке запроса.
После подготовки данных для генератора аннотаций вызывается callProcessor
, который возвращает функцию echo.HandlerFunc
, которую может принять на вход уже оригинальный метод POST
группы echo. Внутри возвращаемой анонимной функции происходит заполнение структуры вызова хэндлера, подготовка принятых файлов, сам вызов, а также обработка ответа. Более подробное описание будет дано в следующем разделе.
Определение параметров запроса
Параметры запроса и ответа могут быть как в теле запроса для POST
, PUT
, PATCH
, так и в пути запроса. Для параметров, получаемых из тела запроса все не сложно для случая использования JSON. Определяется структура, в которую полученные данные раскладываются. На основании этой структуры можно определить, какие данные принимаются, указать их в аннотации. Такой же подход применяется и для параметров ответа для случая, когда ответ в JSON. Берется структура ответа, получаются из нее поля с тегом json
, добавляются в спецификацию ответа.
Параметры запроса, получаемые из пути вызова, определяются при регистрации хэндлера в параметре path
(первый параметр при регистрации). Задается через :
, например /user/:id
. Echo возвращает значение строкой, поэтому такое же ограничение осталось на структуру, которая передается в хэндер, поле с параметром, получаемым из пути, должно быть строкового типа.
Параметры вызова могут быть переданы в query параметрах. На тип поля структуры ограничение аналогично параметрам, получаемым из пути: поле должно быть строкового типа.
Для указания способа получение параметра в структуре запроса используется тег param
. в значение тега задается сначала имя параметра, после способ передачи: path
или query
. Например path:"id,query"
задает query параметр с именем id. Для запросов, в которых не поддерживается получение данных через тело запроса, все поля структуры по умолчанию принимаются через query
параметры. Определение, что часть параметров передается через путь, выполняется автоматически обработкой зарегистрированного пути. Если способ определения параметра не задан, невозможно определить автоматически, устанавливается способ получения через параметры запроса, то есть query
.
Загрузка файлов
Еще один непростой момент при создании спецификации это получение файлов. Определение информации о загружаемых файлах реализовано двумя способами:
Через задание параметров при регистрации обработчика.
Через определение поля у структуры запроса хэндлера.
Такая реализация сделана для добавления гибкости процессу обработки файлов. Если есть необходимость обработать файл самостоятельно, можно просто указать его в параметрах обработчика.
В структуре запроса для указания файла необходимо определить поля типа *multipart.FileHeader
для загрузки одного файла, либо []*multipart.FileHeader
для загрузки множества файлов с одним именем. Спецификация Swagger 2.0 имеет ограничение на определение способа загрузки нескольких файлов, поэтому для такого поля используется не тип file
, а array[string]
с указанием, что формат строк бинарный.
Для определения имени параметра с файлами используется тег param
. Первым и единственным значением задается имя параметра. Если не задано, просматривается тег json
, в противном случае используется имя поля структуры.
При задании получения файла через поле структуры запроса соответствующее поле будет заполнено полученным из echo.Context
значением указателя на multipart.FileHeader
. Получение файлов из запроса выполняется с помощью вызова метода MultipartForm
echo контекста. В результате получаем *multipart.Form
, в котором хранится мапа с массивами файлов. Если требуется один файл, из массива выбирается первый элемент. Если множество, передается весь оригинальный массив.
Параметры аутентификации
В OpenAPI 2.0 поддерживаются следующие способы аутентификации:
Basic
APIKey
OAuth2
Определение, требуется ли аутентификация для определенного хэндлера, выполняется в параметрах при регистрации. Параметры аутентификации определяются структурой AuthType
:
type AuthType struct {
AuthTypeName string
Description string
Scopes []string
BasicAuth *BasicAuthParams
OAuth2 *OAuth2Params
APIKey *APIKeyParams
}
В параметре AuthTypeName
задается уникальный ключ для данного способа аутентификации. В структуре заполняется только один из способов аутентификации. При этом в параметры хэндлера можно передать массив способов, которые поддерживаются. Например, если можно использовать Basic и передавать ключ, то создается отдельно объект с заполненным BasicAuth
отдельно с APIKey
. В HandlerParameters.Auth
передается масив с двумя объектами. В итоговой спецификации будет перечислен массив способов аутентификации.
Регистрация способов в спецификации в поле securityDefinitions
выполняется на основании найденных в хэндлерах способов аутентификации. При обработке каждого хэндлера выполняется сохранение его параметров аутентификации в map[string]AuthType
, после прохода по всем хэндлерам выполняется проход по мапе и определение способа аутентификации.
Данные параметры используются только для генерации аннотаций. Проверка аутентификации и авторизации ложится на разработчиков, которые реализуют хэндлеры. Вероятно, в дальнейшем получение токенов будет добавлено в саму библиотеку. В закрытой реализации на реальном проекте есть еще одна прослойка между хэндлером, зарегистрированным в WrapGroup
, и самим обработчиком логики запроса. Эта прослойка передает объект пользователя в сам обработчик, то есть скрывается логика проверки токена и получения пользователя из базы.
Обработка запроса
При регистрации хэндлеров подобным способом возникает необходимость выполнять вызов конечной функции обработки запроса в самой библиотеке, так как echo сервер вызывает только функцию echo.HandlerFunc
вида:
func(c Context) error
То есть предполагается, что все нужные параметры для работы логики обработчика берутся из echo.Context
. Но при регистрации хэндлера передавалась функция, которая на вход принимает структуру, в которую библиотекой будет заполнены данные из запроса, помещены указатели на структуры для доступа к файлам. Подготовкой этих параметров занимается создаваемая в WrapGroup.callProcessor
функция.
Приведу полный листинг данной функции:
func (g *WrapGroup) callProcessor(path string, handler interface{}, processBody bool) echo.HandlerFunc {
handlerType := reflect.TypeOf(handler)
inParamsCount := handlerType.NumIn()
outParamsCount := handlerType.NumOut()
reqParam := handlerType.In(1)
pathParamNames := make([]string, 0)
pParams := regexp.MustCompile(`:\w+`).FindAllString(path, -1)
for _, param := range pParams {
pName := param[1:]
pathParamNames = append(pathParamNames, pName)
}
queryParams, pathParams := g.getParams(reqParam, pathParamNames, processBody)
fParams := g.getUploadFileParams(reqParam)
handlerFunc := reflect.ValueOf(handler)
return func(c echo.Context) error {
inputVal := reflect.New(reqParam)
if processBody {
if len(fParams) == 0 {
inputValPtr := inputVal.Interface()
err := json.NewDecoder(c.Request().Body).Decode(inputValPtr)
if err != nil {
return fmt.Errorf("could not decode req body json: %w", err)
}
inputVal = reflect.ValueOf(inputValPtr)
} else {
mForm, err := c.MultipartForm()
if err != nil {
return fmt.Errorf("cannot get multipart form: %w", err)
}
err = g.processMultipartUpload(mForm, inputVal, fParams)
if err != nil {
return fmt.Errorf("cannot process multipart form: %w", err)
}
}
}
inputVal = inputVal.Elem()
for _, pParamName := range pathParams {
paramVal := c.Param(pParamName.ParamName)
fItem := inputVal.FieldByName(pParamName.StructFieldName)
fItem.Set(reflect.ValueOf(paramVal))
}
for _, qParamName := range queryParams {
paramVal := c.QueryParam(qParamName.ParamName)
fItem := inputVal.FieldByName(qParamName.StructFieldName)
fItem.Set(reflect.ValueOf(paramVal))
}
inValues := make([]reflect.Value, 0)
inValues = append(inValues, reflect.ValueOf(c.Request().Context()))
inValues = append(inValues, inputVal)
if inParamsCount > 2 {
inValues = append(inValues, reflect.ValueOf(c.Request()))
}
if inParamsCount > 3 {
inValues = append(inValues, reflect.ValueOf(c.Response()))
}
results := handlerFunc.Call(inValues)
var errVal reflect.Value
if outParamsCount == 1 {
errVal = results[0]
} else {
errVal = results[1]
}
errInterface := errVal.Interface()
var resultErr error = nil
if errInterface != nil {
var ok bool
resultErr, ok = errInterface.(error)
if !ok {
return fmt.Errorf("calling %s: callback cannot process error: %v", handlerType.String(), errInterface)
}
}
if resultErr != nil {
return c.JSON(http.StatusInternalServerError, resultErr)
}
if outParamsCount == 2 {
output := results[0].Interface()
return c.JSON(http.StatusOK, output)
}
return nil
}
}
Часть работы по обработке запроса вынесена за пределы возвращаемой анонимной функции, чтобы не выполнять ее на каждом вызове. До обработки запроса можно определить, какие есть параметры вызова, что будет получено из пути, что из тела запроса, какие файлы собираемся принимать.
Далее на каждом запросе выполняется заполнение структуры вызова из параметров, передаваемых в теле запроса, получаемых из пути вызова, а также из query-параметров. При обработке параметров из тела запроса необходимо отличать тип запроса: если передается multipart форма, нужно из нее получить часть, в которой идет JSON для разбора. В противном случае все тело вызова обрабатывается как JSON и декодируется в структуру вызова.
Если в теле запроса ожидалась передача файлов, то есть были поля у структуры с типом *multipart.FileHeader
, выполняется получение этих параметров из запроса и помещение их в поля структуры.
После обработки тела выполняется заполнение полей остальными параметрами. Здесь реализована та рутинная работа, которую приходилось бы писать в коде каждого обработчика, то есть получение из echo.Context
параметров.
Когда завершено заполнение структуры выполняется подготовка к вызову самого зарегистрированного обработчика. На этом этапе были срезаны некоторые углы, понимание параметров вызова выполняется на основании количества параметров в обработчике. На этапе регистрации обработчика вызывалась проверка его сигнатуры, так что здесь исключена ситуация, что будет ошибка с передачей параметров.
После чего происходит вызов обработчика, получение результата работы и ошибки. Если ошибка присутствует, она возвращается из функции, далее ее обработает echo. Если же ошибки нет, происходит возврат JSON со статусом 200.
Пример сервера
В реализованном сервере с демонстрацией использования генератора вместе с оберткой над группой выполнена иммитация API для управления пользователями:
POT /users/create
- создание пользователя через POST запрос с аутентификацией. В примере запроса добавлены поля с загрузкой одного файла -avatar
, множества -doc
, а также не указанного в структуре запроса файлаcustom_file
;POST /users/update/:id
- обновление пользователя POST запросом.id
пользователя задается через путь, в теле запроса передается новое имя;POST /users/:id/avatar
- обновление пользователя POST запросом с загрузкой файла.id
пользователя задается через путь, в теле запроса загружается файл. Добавлен query параметрforce
, не смог придумать что-то более уместное для смешения body и query параметров;GET /users/:id
- получение пользователя поid
в пути запроса;GET /users/:id/avatar
- получение статического файла, поid
в пути запроса;GET /users/list
- получение списка пользователей, не требуется аутентификация, есть query параметры;DELETE /users/:id
- демонстрация DELETE метода,id
пользователя передается в пути.
Для данного сервера была сгенерирована аннотация:
Итог
Исходный код библиотеки выложен:
GitHub - AlhimicMan/goswag: Swagger annotations generator
Ее можно использовать как полноценную обертку как в примере сервера, так и в виде отдельно генератора. Но для этого придется либо готовить отдельно аннотации, что само по себе затруднительно, при таком подходе может быть проще использовать имебщиеся решения, которые генерирую swagger на основании доков перед функцией обработки. Либо написать свою реализацию вызовов обработчиков вместо callProcessor
, но использовать имеющийся подход.
При написании библиотеки я не ставил целью на текущем этапе реализовать все возможные сценарии работы с echo. Но даже в текущем виде она может стать хорошей заменой инфраструктурному коду, который готовит параметры, передаваемые уровень бизнес-логики.
Буду рад комментариям к данной статье, issue на github, а лучше сразу pull-request. На данный момент точно есть проблема с возвратом ошибок. Ну и дополнительные кейсы использования, которые я не предусмотрел.
Комментарии (13)
mr_tron
06.02.2023 15:29+2>До разработки на Go я много писал проектов на Django и FastAPI.
Это объясняет то что вы хотите использовать свагер не правильно. Не хочу обидить питон разработчиков - я сам в каком-то смысле питон разработчик, но в питоне в силу динамической типизации плохо работает вариант "генерировать код из свагера".
В го надо делать строго наоборот - писать свагер и из него генерировать и код и сервер. Причем рекомендую ogen (реклама его уже была на хабре) вместо oapi-codegen. Попробуйте и поймете абсолютно другой уровень комфорта.funca
06.02.2023 15:42Возможность сгенерировать Swagger не отменяет подход, когда Swagger пишется вначале руками (API-first approach). В процессе разработки вы можете использовать сгенерированное описание для проверки соответствия реализации исходной спецификации.
paramtamtam
06.02.2023 17:32А это не зона ли ответственности E2E тестов, разве?
ko_0n
06.02.2023 18:39+1Скорее зона ответственности unit тестов. Но везде есть человека человеческий фактор. Спецификация, swagger, не должна меняться, если при реализации допустили ошибку. А она может измениться, поэтому swagger не должен меняться при измерении кода.
funca
06.02.2023 20:51Это называется Schema-based Contact Testing. Подход находится выше юнит тестов потому, что использует данные снаружи. Но ниже E2E - их можно выполнять в изоляции локально или на CI - без развертывания приложения и его зависимостей. Удобно, когда у вас много микросервисов со своими релизными циклами и развертывание всего стека в рамках CI невозможно или нецелесообразно. По смыслу это близко к статистикой проверке типов, где Swagger (OpenAPI, AsyncAPI) вступают в роли унифицированной (платформонезависимой) нотации описания интерфейсов.
AlhimicMan Автор
06.02.2023 19:27Я в своих идеях отталкивался как раз от необходимости строить доку на основании того, что получилось на выходе из разработки. Мне понравилось генерировать код для grpc, но все же при возможности я хотел бы писать структуры сам, сладить, что в них поменялось на выходе, в нужных местах заполнять поля. А не так, что "вот мы тебе сделали, теперь пойди угадай, что же поменялось и везде где надо поиспользуй поля". Может это мой пиьонячий подход играет
Ztare
06.02.2023 20:04+3Смотрите - подход со swagger-first в небольших проектах часто неудобен. Если разработчик fullstack тоже.
swagger-first на бекенде
- добавляем файл swagger
- добавляем в зависимость утилиту генерации бекенда
- следим чтобы генерация соответствовала файлу и периодически генерим начисто
- вместо написания кода на языке бекенда - пишем сначала свагер (+1 языковая технология в стек проекта)
backend-first на бекенде
- добавляем код публикации свагера и все, работаем как обычно
Пока сборка проекта и среды разработки не будут поддерживать swagger как часть кода, второй вариант будут принимать часто как наиболее удобный и простой в поддержке. Если подключенный swagger даст в проекте автоматически вложенные в него модели (как фейковые классы по аналогии как сделаны в android элементы activity) и примапит хендлеры по какому-то правилу, тогда скорее всего повсеместно откажутся от генерации из кодаvtb_k
08.02.2023 09:12+1Так не пройдет, когда из сваггера еще фронт на тайпскрипте генерируется. А это намного удобнее, чем фронтам постоянно под изменяющийсщ сваггер подстраиваться
Ztare
09.02.2023 02:20Я про бекенд - на фронтенде все в обоих вариантах одинаково, там без утилит не обойтись в любом случае. Ну и в нормальных местах бекендеры не ломают API от нефиг делать - на ревью и проектировании обязаны следить за совместимостью
vtb_k
09.02.2023 14:20Нет, не одинаково. Потому что мы готовим сваггер файл отдельным пулл реквестом, одобряем его со стороны фронта и продакт менеджера, мержим его в мастер. И потом и бек и фронт работают отдельно на основе уже одобренного контракта. Для фронта оставшаяся интеграция — это где-то 10% работы после завершения бека.
paramtamtam
Мне вот всегда было интересно, чем же руководствуются те, что генерируют спецификацию из кода? Ведь спецификация есть нечто иное, как контракт для взаимодействия между системами, т.е. она должна являться источником истины, а не наоборот.
В реальном мире это можно представить как взаимоотношение, например, клиента и банка. Условия, на которых банк оказываем услуги (не обнуляет ваши счета, начисляет проценты по вкладам и так далее) зависели бы от того, как банк фактически обходится с вашими средствами, а не от того, на каких условиях вы заключили с ним контракт (договор, условия обслуживая и т.п.).
Борис, вы безусловно большой молодец, что разобрались со своим кейсом и поделились опытом! Но мне вот просто интересно - почему бы не описать спецификацию в yaml/json формате, и из неё уже сгенерировать код под Echo сервер используя, например, deepmap/oapi-codegen? Вносить изменения будет так же просто - изменили спеку (контракт), сгенерировали соответствующий код, поправили имплементацию. Но правило источника истины в этом случае сохраняется, и бережёт вас же от обратно-несовместимых изменений, и значительно упрощает ревью. Да и кода будет меньше, и поддерживать 3rd party решение вам не придётся.
funca
Как будто в жизни так не бывает? У меня летом в банке сняли 30 дополнительных $ за swift перевод просто потому, что у них в зависимостях имплементации что-то поменялось.
AlhimicMan Автор
Мне кажется, мы как раз про разные подходы говорим, и ни один из них не лучше или хуже другого. Следовать строго оговоренное документации заранее приятно и удобно. И главное, что можно доверять друг другу, договорившись на берегу.
Но бывают случаи, когда нужно строить документацию на основании того, что получилось на выходе из разработки. Когда ты написал северную часть, сразу же показал фронту, он пошел на основании этого реализовывать свое. И любые изменения он тоже может отследить.
Правильный комментарий ниже, что это может быть моя травма от python. Но раз там так носят, значит и в Go можно. Кроме того, приведенные мной библиотеки в примерах имеющихся реализаций способны строить аннотацию по коду. Но делают это не самым удобным для меня способом