ogen: OpenAPI v3 генератор для Go

—

от автора

Чем больше кода, тем больше багов. Проект ogen генерирует код по OpenAPI спецификации, избавляя от сотен (или даже тысяч) строк скучного шаблонного кода на Go, который приходится писать вручную с риском допустить опечатку или ошибку.

Генератор пишет клиент и сервер, а разработчику остаётся только реализовать интерфейс для сервера. И никаких interface{} и рефлексии, только строгая типизация и кодогенерация.

Я расскажу, чем ogen отличается от существующих решений и почему стоит его попробовать.

Строгая типизация

Генерируется строго-типизированный клиент и сервер, чем-то похоже на gRPC. Дополняется описанием из спецификации в комментариях.

Для сервера генерируется интерфейс, который нужно имплементировать:

// Handler handles operations described by OpenAPI v3 specification. type Handler interface {     // AddPet implements addPet operation.     //     // Creates a new pet in the store. Duplicates are allowed.     //     // POST /pets     AddPet(ctx context.Context, req NewPet) (AddPetRes, error)     // DeletePet implements deletePet operation.     //     // Deletes a single pet based on the ID supplied.     //     // DELETE /pets/{id}     DeletePet(ctx context.Context, params DeletePetParams) (DeletePetRes, error)     // FindPetByID implements find pet by id operation.     //     // Returns a user based on a single ID, if the user does not have access to the pet.     //     // GET /pets/{id}     FindPetByID(ctx context.Context, params FindPetByIDParams) (FindPetByIDRes, error)     // FindPets implements findPets operation.     //     // Returns all pets from the system that the user has access to     //     // GET /pets     FindPets(ctx context.Context, params FindPetsParams) (FindPetsRes, error)     // PatchPet implements patchPet operation.     //     // Patch a pet.     //     // PATCH /pets/{id}     PatchPet(ctx context.Context, req UpdatePet, params PatchPetParams) (PatchPetRes, error) }

Клиент генерируется аналогично:

func (c *Client) AddPet(ctx context.Context, request NewPet) (res AddPetRes, err error) {}  // PatchPet invokes patchPet operation. // // Patch a pet. // // PATCH /pets/{id}   func (c *Client) PatchPet(ctx context.Context, request UpdatePet, params PatchPetParams) (res PatchPetRes, err error) {} 

Валидация

В ogen поддержаны maxLength, minLength, pattern (regex), minimum, maximum и другие валидаторы строк, массивов, объектов и чисел, для которых статически генерируются проверки на клиенте и сервере.

UpdatePet:     type: object     properties:       name:         type: string         maxLength: 25         minLength: 3         pattern: '^[a-zA-Z0-9]+$'       tag:         maxLength: 10         minLength: 1         pattern: '^[a-zA-Z0-9]+$'         nullable: true         type: string

Неизвестные и обязательные поля

Более того, эффективно проверяется, что обязательные поля заданы, а неизвестные (если не разрешены) не передаются:

// Validate required fields. var failures []validate.FieldError for i, mask := range [1]uint8{     0b00000001, } {     if result := (requiredBitSet[i] & mask) ^ mask; result != 0 {         // Mask only required fields and check equality to mask using XOR.         //         // If XOR result is not zero, result is not equal to expected, so some fields are missed.         // Bits of fields which would be set are actually bits of missed fields.         missed := bits.OnesCount8(result)         for bitN := 0; bitN < missed; bitN++ {             bitIdx := bits.TrailingZeros8(result)             fieldIdx := i*8 + bitIdx             var name string             if fieldIdx < len(jsonFieldsNameOfNewPet) {                 name = jsonFieldsNameOfNewPet[fieldIdx]             } else {                 name = strconv.Itoa(fieldIdx)             }             failures = append(failures, validate.FieldError{                 Name:  name,                 Error: validate.ErrFieldRequired,             })             // Reset bit.             result &^= 1 << bitIdx         }     } }

Enum

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

// Ref: #/components/schemas/Kind   type Kind string    const (      KindCat   Kind = "Cat"      KindDog   Kind = "Dog"      KindFish  Kind = "Fish"      KindBird  Kind = "Bird"      KindOther Kind = "Other"   )  func (s Kind) Validate() error {     switch s {     case "Cat":         return nil     case "Dog":         return nil     case "Fish":         return nil     case "Bird":         return nil     case "Other":         return nil     default:         return errors.Errorf("invalid value: %v", s)     } }  // Decode decodes Kind from json. func (s *Kind) Decode(d *jx.Decoder) error {     if s == nil {         return errors.New("invalid: unable to decode Kind to nil")     }     v, err := d.StrBytes()     if err != nil {         return err     }     // Try to use constant string.     switch Kind(v) {     case KindCat:         *s = KindCat     case KindDog:         *s = KindDog     case KindFish:         *s = KindFish     case KindBird:         *s = KindBird     case KindOther:         *s = KindOther     default:         *s = Kind(v)     }      return nil } 

Тот же deepmap/oapi-codegen не проверяет значения enum-ов, только генерируя новый тип и константы.

Без указателей

Там, где это возможно.

В большинстве случаев, для опциональных (или nullable) полей в Go принято использовать указатели:

type Pet struct {     // Name of the pet     Name string `json:"name"`      // Type of the pet     Tag *string `json:"tag,omitempty"` }

Это пусть и привычный, но семантический костыль:

  • Можно легко получить null pointer exception, привет The Billion Dollar Mistake
  • Больше нагрузка на сборщик мусора, особенно если объектов много или они вложенные (например, слайс из таких []Pet)
  • Невозможно выразить nullable optional, когда может быть передано три состояния: пустота, null и заполненное значение. Особенно полезно для PATCH-операций.

В ogen это решается через генерацию обобщенных типов (дженерики пробовали использовать, но в этом случае они не подошли):

// Ref: #/components/schemas/NewPet   type NewPet struct {      Name string    `json:"name"`      Tag  OptString `json:"tag"`   }  // OptString is optional string. type OptString struct {      Value string      Set   bool   }

С optional nullable deepmap/oapi-codegen не справился:

// UpdatePet defines model for UpdatePet.   type UpdatePet struct {      Name *string `json:"name,omitempty"`      Tag  *string `json:"tag"`   }

А ogen сгенерировал дополнительный тип OptNilString:

// Ref: #/components/schemas/UpdatePet   type UpdatePet struct {      Name OptString    `json:"name"`      Tag  OptNilString `json:"tag"`   }  // OptNilString is optional nullable string. type OptNilString struct {      Value string      Set   bool      Null  bool   }

С помощью OptNilString можно выразить и отсутствие значения, и null, и значение пустой строки, и просто строку.

Массивы

Для массивов дополнительный тип можно не генерировать, изменяя семантику nil значения слайса в зависимости от схемы. Например, если поле optional, то nil будет означать отсутствие значения, а если nullable, то null. Для optional nullable поля уже придется сгенерировать обертку.

JSON Без Рефлексии

Отказ от рефлексии достигается за счет того, что ogen не использует стандартный encoding/json с его ограничениями по скорости и возможностям, а генерирует статические энкодеры и декодеры:

// Encode encodes string as json. func (o OptNilString) Encode(e *jx.Encoder) {      if !o.Set {         return      }      if o.Null {         e.Null()         return      }      e.Str(string(o.Value))   }  

Это помогает сделать работу с json эффективнее и гибче, например, декодинг поля в несколько проходов для поддержки oneOf с дискриминатором (сначала парсится значение поля-дискриминатора, а потом уже значение целиком) и без (сначала обходятся все поля и тип выбирается по уникальным полям).

В качестве библиотеки для работы с json используется go-faster/jx, сильно переработанный и оптимизированный форк jsoniter-а (может парсить почти гигабайт json логов в секунду на ядро, а писать — больше двух).

Без внешнего роутера

Для того, чтобы не выбирать между echo и chi, ogen использует свой, эффективный статически сгенерированный роутер на основе radix tree:

// ... // Static code generated router with unwrapped path search. switch { default:     if len(elem) == 0 {         break     }     switch elem[0] {     case '/': // Prefix: "/pets"         if l := len("/pets"); len(elem) >= l && elem[0:l] == "/pets" {             elem = elem[l:]         } else {             break         }          if len(elem) == 0 {             switch r.Method {             case "GET":                 s.handleFindPetsRequest([0]string{}, w, r)             case "POST":                 s.handleAddPetRequest([0]string{}, w, r)             default:                 s.notAllowed(w, r, "GET,POST")             }              return         }         switch elem[0] {         case '/': // Prefix: "/"             if l := len("/"); len(elem) >= l && elem[0:l] == "/" {                 elem = elem[l:]             } else {                 break             } // ...

Статический роутер позволяет компилятору сделать множество оптимизаций: убрать лишние проверки на длину строки, сгенерировать эффективный код для сравнения префиксов вместо runtime.cmpstring, использовать оптимальный алгоритм поиска нужного case в switch вместо бинарного поиска, и т.д.

Всё это позволяет достичь скорости в несколько раз выше, чем у chi и echo (код бенчмарка):

name                        time/op Router/GithubStatic/ogen-4  18.7ns ± 3% Router/GithubStatic/chi-4    146ns ± 2% Router/GithubStatic/echo-4  73.7ns ± 9% Router/GithubParam/ogen-4   34.0ns ± 3% Router/GithubParam/chi-4     251ns ± 3% Router/GithubParam/echo-4    118ns ± 2% Router/GithubAll/ogen-4     56.6µs ± 3% Router/GithubAll/chi-4       323µs ± 3% Router/GithubAll/echo-4      173µs ± 4%  name                        alloc/op Router/GithubStatic/ogen-4   0.00B Router/GithubStatic/chi-4    0.00B Router/GithubStatic/echo-4   0.00B Router/GithubParam/ogen-4    0.00B Router/GithubParam/chi-4     0.00B Router/GithubParam/echo-4    0.00B Router/GithubAll/ogen-4      0.00B Router/GithubAll/chi-4       0.00B Router/GithubAll/echo-4      0.00B

OneOf

Возьмем что-то вроде такой тип-суммы:

Dog:     type: object     required:       - kind     properties:       kind:         $ref: '#/components/schemas/Kind'       bark:         type: string   Cat:     type: object     required:       - kind     properties:       kind:         $ref: '#/components/schemas/Kind'       meow:         type: string   SomePet:     type: object     discriminator:       propertyName: kind     oneOf:       - $ref: '#/components/schemas/Dog'       - $ref: '#/components/schemas/Cat'

Её ogen сгенерирует следующим образом:

// Ref: #/components/schemas/Cat type Cat struct {     Kind Kind      `json:"kind"`     Meow OptString `json:"meow"` }  // Ref: #/components/schemas/Dog type Dog struct {     Kind Kind      `json:"kind"`     Bark OptString `json:"bark"` }  // Ref: #/components/schemas/SomePet // SomePet represents sum type. type SomePet struct {     Type SomePetType // switch on this field     Dog  Dog     Cat  Cat }

И будет использовать дискриминатор сразу при парсинге:

// func (s *SomePet) Decode(d *jx.Decoder) error if err := d.Capture(func(d *jx.Decoder) error {     return d.ObjBytes(func(d *jx.Decoder, key []byte) error {         if found {             return d.Skip()         }         switch string(key) {         case "kind":             typ, err := d.Str()             if err != nil {                 return err             }             switch typ {             case "Cat":                 s.Type = CatSomePet                 found = true             case "Dog":                 s.Type = DogSomePet                 found = true             default:                 return errors.Errorf("unknown type %s", typ)             }             return nil         }         return d.Skip()     }) }); err != nil {     return errors.Wrap(err, "capture") } if !found {     return errors.New("unable to detect sum type variant") } switch s.Type { case DogSomePet:     if err := s.Dog.Decode(d); err != nil {         return err     } case CatSomePet:     if err := s.Cat.Decode(d); err != nil {         return err     } default:     return errors.Errorf("inferred invalid type: %s", s.Type) }

Тот же deepmap/oapi-codegen предполагает дополнительный ручной вызов (ну и на момент написания статьи, сгененированный им код сломан):

// SomePet defines model for SomePet. type SomePet struct {     union json.RawMessage }  func (t SomePet) Discriminator() (string, error) {     var discriminator struct {         Discriminator string `json:"kind"`     }     err := json.Unmarshal(t.union, &discriminator)     return discriminator.Discriminator, err }  // AsCat returns the union data inside the SomePet as a Cat func (t SomePet) AsCat() (Cat, error) {     var body Cat     err := json.Unmarshal(t.union, &body)     return body, err } 

Видимо, пользователь должен сам вызвать Discriminator, написать switch по возможным значениям и вызывать AsT() (T, error) в зависимости от значений.

Без дискриминатора

Более того, ogen может работать вообще без поля-дискриминатора, выбирая тип по уникальным полям:

var found bool if err := d.Capture(func(d *jx.Decoder) error {     return d.ObjBytes(func(d *jx.Decoder, key []byte) error {         switch string(key) {         case "bark":             match := DogSomePet             if found && s.Type != match {                 s.Type = ""                 return errors.Errorf("multiple oneOf matches: (%v, %v)", s.Type, match)             }             found = true             s.Type = match         case "meow":             match := CatSomePet             if found && s.Type != match {                 s.Type = ""                 return errors.Errorf("multiple oneOf matches: (%v, %v)", s.Type, match)             }             found = true             s.Type = match         }         return d.Skip()     }) }); err != nil {     return errors.Wrap(err, "capture") }

Если есть поле meow, то тип Cat, если bark — Dog, а если не нашли, то будет ошибка unable to detect sum type variant.

Я не уверен, что знаю какой либо генератор для OpenAPI, который бы смог справиться с такой задачей, как минимум на Go.

Сообщения об ошибках

Подробные цветные сообщения об ошибках с контекстом и ссылкой на конкретное место:

$ go generate   - petstore-expanded.yaml:218:17 -> resolve: can't find value for "components/schemas/Do1"           217 |       oneOf:             → 218 |         - $ref: '#/components/schemas/Do1'                     |                 ↑                  219 |         - $ref: '#/components/schemas/Cat'                 220 |                    221 |     UpdatePet: 

В итоге

Основные преимущества ogen, которые я вижу:

  • Строгая типизация клиента и сервера
  • Валидация
  • Поддержка oneOf и anyOf, в том числе без дискриминаторов
  • Возможность представить nullable optional
  • Встроенный быстрый статический роутер
  • Быстрая работа с json
  • Удобные сообщения об ошибках в схеме


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


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *