Как стать автором
Обновить

Генерация Swagger для сервера Echo

Время на прочтение12 мин
Количество просмотров3.7K

В процессе работы над проектом потребовалось генерировать аннотации 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для группировки по путям, и непосредственно регистрация самих хэндлеров с указанием методов обработки запроса: GETPOST и тп.

Пример функции обработки 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. Внутри возвращаемой анонимной функции происходит заполнение структуры вызова хэндлера, подготовка принятых файлов, сам вызов, а также обработка ответа. Более подробное описание будет дано в следующем разделе.

Определение параметров запроса

Параметры запроса и ответа могут быть как в теле запроса для POSTPUTPATCH, так и в пути запроса. Для параметров, получаемых из тела запроса все не сложно для случая использования JSON. Определяется структура, в которую полученные данные раскладываются. На основании этой структуры можно определить, какие данные принимаются, указать их в аннотации. Такой же подход применяется и для параметров ответа для случая, когда ответ в JSON. Берется структура ответа, получаются из нее поля с тегом json, добавляются в спецификацию ответа.

Параметры запроса, получаемые из пути вызова, определяются при регистрации хэндлера в параметре path(первый параметр при регистрации). Задается через :, например /user/:id. Echo возвращает значение строкой, поэтому такое же ограничение осталось на структуру, которая передается в хэндер, поле с параметром, получаемым из пути, должно быть строкового типа.

Параметры вызова могут быть переданы в query параметрах. На тип поля структуры ограничение аналогично параметрам, получаемым из пути: поле должно быть строкового типа.

Для указания способа получение параметра в структуре запроса используется тег param. в значение тега задается сначала имя параметра, после способ передачи: path или query. Например path:"id,query" задает query параметр с именем id. Для запросов, в которых не поддерживается получение данных через тело запроса, все поля структуры по умолчанию принимаются через query параметры. Определение, что часть параметров передается через путь, выполняется автоматически обработкой зарегистрированного пути. Если способ определения параметра не задан, невозможно определить автоматически, устанавливается способ получения через параметры запроса, то есть query.

Загрузка файлов

Еще один непростой момент при создании спецификации это получение файлов. Определение информации о загружаемых файлах реализовано двумя способами:

  1. Через задание параметров при регистрации обработчика.

  2. Через определение поля у структуры запроса хэндлера.

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

В структуре запроса для указания файла необходимо определить поля типа *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. На данный момент точно есть проблема с возвратом ошибок. Ну и дополнительные кейсы использования, которые я не предусмотрел.

Теги:
Хабы:
+1
Комментарии13

Публикации

Истории

Работа

Go разработчик
132 вакансии

Ближайшие события

Weekend Offer в AliExpress
Дата20 – 21 апреля
Время10:00 – 20:00
Место
Онлайн
Конференция «Я.Железо»
Дата18 мая
Время14:00 – 23:59
Место
МоскваОнлайн