Pull to refresh

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

Reading time 8 min
Views 10K

Чем больше кода, тем больше багов. Проект 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, если barkDog, а если не нашли, то будет ошибка 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
  • Удобные сообщения об ошибках в схеме
Tags:
Hubs:
+16
Comments 6
Comments Comments 6

Articles