メインコンテンツへスキップする

Ent と ogen で REST CRUD APIを自動生成

· 1 分で読む

2021年の末、 EntOpenAPI Specification に完全に準拠したドキュメントを生成する公式拡張機能を発表しました。 entoas です

今日、entoas のための新しい拡張機能 ogent を発表します。 これは 、entoas が生成する OpenAPI ドキュメントから、ogen (website) によって型安全かつリフレクションフリーな実装を提供します。

ogen は OpenAPI v3 ドキュメントのための、 opinionated な Go コードジェネレータです。 ogen は OpenAPI ドキュメントからサーバーとクライアント両方の実装を生成します。 ユーザーに残される唯一の仕事は、アプリケーションのデータレイヤーにアクセスするためのインターフェイスを実装することです。 ogen はいくつかの素晴らしい機能を持っています。その一つが OpenTelemetry との統合です。 ぜひ試して、好きになってください。

この記事で紹介する拡張機能は、ogen が生成するコードと Ent との橋渡しをします。 entoas の設定を使用して、 ogen が出力するコードの欠けている部分を補完します。

次の図は、Ent が entoasogent 両方の拡張機能とどのように相互作用し、ogen がどのように統合されるかを示しています。

Diagram

Diagram

あなたが Ent を利用したことがなく、異なる種類のデータベースへの接続方法、マイグレーションの実行方法、エンティティの扱い方など、Ent の詳細を知りたい場合は、セットアップチュートリアルをご覧ください。

The code in this post is available in the modules examples.

はじめましょう

While Ent does support Go versions 1.16+ ogen requires you to have at least version 1.17. :::

To use the ogent extension use the entc (ent codegen) package as described here. First install both entoas and ogent extensions to your Go module:

go get ariga.io/ogent@main

Now follow the next two steps to enable them and to configure Ent to work with the extensions:

1. Create a new Go file named ent/entc.go and paste the following content:

ent/entc.go
//go:build ignore

package main

import (
"log"

"ariga.io/ogent"
"entgo.io/contrib/entoas"
"entgo.io/ent/entc"
"entgo.io/ent/entc/gen"
"github.com/ogen-go/ogen"
)

func main() {
spec := new(ogen.Spec)
oas, err := entoas.NewExtension(entoas.Spec(spec))
if err != nil {
log.Fatalf("creating entoas extension: %v", err)
}
ogent, err := ogent.NewExtension(spec)
if err != nil {
log.Fatalf("creating ogent extension: %v", err)
}
err = entc.Generate("./schema", &gen.Config{}, entc.Extensions(ogent, oas))
if err != nil {
log.Fatalf("running ent codegen: %v", err)
}
}

2. Edit the ent/generate.go file to execute the ent/entc.go file:

ent/generate.go
package ent

//go:generate go run -mod=mod entc.go

With these steps complete, all is set up for generating an OAS document and implementing server code from your schema!

CRUD HTTP API サーバーを生成

The first step on our way to the HTTP API server is to create an Ent schema graph. For the sake of brevity, here is an example schema to use:

ent/schema/todo.go
package schema

import (
"entgo.io/ent"
"entgo.io/ent/schema/field"
)

// Todo holds the schema definition for the Todo entity.
type Todo struct {
ent.Schema
}

// Fields of the Todo.
func (Todo) Fields() []ent.Field {
return []ent.Field{
field.String("title"),
field.Bool("done"),
}
}

The code above is the "Ent way" to describe a schema-graph. In this particular case we created a todo entity.

Now run the code generator:

go generate ./...

You should see a bunch of files generated by the Ent code generator. The file named ent/openapi.json has been generated by the entoas extension. Here is a sneak peek into it:

ent/openapi.json
{
"info": {
"title": "Ent Schema API",
"description": "This is an auto generated API description made out of an Ent schema definition",
"termsOfService": "",
"contact": {},
"license": {
"name": ""
},
"version": "0.0.0"
},
"paths": {
"/todos": {
"get": {
[...]
Swagger Editor Example

Swagger Editor Example

However, this post focuses on the server implementation part therefore we are interested in the directory named ent/ogent. All the files ending in _gen.go are generated by ogen. The file named oas_server_gen.go contains the interface ogen-users need to implement in order to run the server.

ent/ogent/oas_server_gen.go
// Handler handles operations described by OpenAPI v3 specification.
type Handler interface {
// CreateTodo implements createTodo operation.
//
// Creates a new Todo and persists it to storage.
//
// POST /todos
CreateTodo(ctx context.Context, req CreateTodoReq) (CreateTodoRes, error)
// DeleteTodo implements deleteTodo operation.
//
// Deletes the Todo with the requested ID.
//
// DELETE /todos/{id}
DeleteTodo(ctx context.Context, params DeleteTodoParams) (DeleteTodoRes, error)
// ListTodo implements listTodo operation.
//
// List Todos.
//
// GET /todos
ListTodo(ctx context.Context, params ListTodoParams) (ListTodoRes, error)
// ReadTodo implements readTodo operation.
//
// Finds the Todo with the requested ID and returns it.
//
// GET /todos/{id}
ReadTodo(ctx context.Context, params ReadTodoParams) (ReadTodoRes, error)
// UpdateTodo implements updateTodo operation.
//
// Updates a Todo and persists changes to storage.
//
// PATCH /todos/{id}
UpdateTodo(ctx context.Context, req UpdateTodoReq, params UpdateTodoParams) (UpdateTodoRes, error)
}

ogent adds an implementation for that handler in the file ogent.go. To see how you can define what routes to generate and what edges to eager load please head over to the entoas documentation.

The following shows an example for a generated READ route:

// ReadTodo handles GET /todos/{id} requests.
func (h *OgentHandler) ReadTodo(ctx context.Context, params ReadTodoParams) (ReadTodoRes, error) {
q := h.client.Todo.Query().Where(todo.IDEQ(params.ID))
e, err := q.Only(ctx)
if err != nil {
switch {
case ent.IsNotFound(err):
return &R404{
Code: http.StatusNotFound,
Status: http.StatusText(http.StatusNotFound),
Errors: rawError(err),
}, nil
case ent.IsNotSingular(err):
return &R409{
Code: http.StatusConflict,
Status: http.StatusText(http.StatusConflict),
Errors: rawError(err),
}, nil
default:
// Let the server handle the error.
return nil, err
}
}
return NewTodoRead(e), nil
}

サーバーを実行

The next step is to create a main.go file and wire up all the ends to create an application-server to serve the Todo-API. The following main function initializes a SQLite in-memory database, runs the migrations to create all the tables needed and serves the API as described in the ent/openapi.json file on localhost:8080:

main.go
package main

import (
"context"
"log"
"net/http"

"entgo.io/ent/dialect"
"entgo.io/ent/dialect/sql/schema"
"<your-project>/ent"
"<your-project>/ent/ogent"
_ "github.com/mattn/go-sqlite3"
)

func main() {
// Create ent client.
client, err := ent.Open(dialect.SQLite, "file:ent?mode=memory&cache=shared&_fk=1")
if err != nil {
log.Fatal(err)
}
// Run the migrations.
if err := client.Schema.Create(context.Background(), schema.WithAtlas(true)); err != nil {
log.Fatal(err)
}
// Start listening.
srv, err := ogent.NewServer(ogent.NewOgentHandler(client))
if err != nil {
log.Fatal(err)
}
if err := http.ListenAndServe(":8080", srv); err != nil {
log.Fatal(err)
}
}

After you run the server with go run -mod=mod main.go you can work with the API.

First, let's create a new Todo. For demonstration purpose we do not send a request body:

curl -X POST -H "Content-Type: application/json" localhost:8080/todos
{
"error_message": "body required"
}

As you can see ogen handles that case for you since entoas marked the body as required when attempting to create a new resource. Let's try again, but this time provide a request body:

curl -X POST -H "Content-Type: application/json" -d '{"title":"Give ogen and ogent a Star on GitHub"}'  localhost:8080/todos
{
"error_message": "decode CreateTodo:application/json request: invalid: done (field required)"
}

Ooops! What went wrong? ogen has your back: the field done is required. To fix this head over to your schema definition and mark the done field as optional:

ent/schema/todo.go
package schema

import (
"entgo.io/ent"
"entgo.io/ent/schema/field"
)

// Todo holds the schema definition for the Todo entity.
type Todo struct {
ent.Schema
}

// Fields of the Todo.
func (Todo) Fields() []ent.Field {
return []ent.Field{
field.String("title"),
field.Bool("done").
Optional(),
}
}

Since we made a change to our configuration, we have to re-run code generation and restart the server:

go generate ./...
go run -mod=mod main.go

Now, if we attempt to create the Todo again, see what happens:

curl -X POST -H "Content-Type: application/json" -d '{"title":"Give ogen and ogent a Star on GitHub"}'  localhost:8080/todos
{
"id": 1,
"title": "Give ogen and ogent a Star on GitHub",
"done": false
}

Voila, there is a new Todo item in the database!

Assume you have completed your Todo and starred both ogen and ogent (you really should!), mark the todo as done by raising a PATCH request:

curl -X PATCH -H "Content-Type: application/json" -d '{"done":true}'  localhost:8080/todos/1
{
"id": 1,
"title": "Give ogen and ogent a Star on GitHub",
"done": true
}

カスタムエンドポイントを追加

As you can see the Todo is now marked as done. Though it would be cooler to have an extra route for marking a Todo as done: PATCH todos/:id/done. To make this happen we have to do two things: document the new route in our OAS document and implement the route. We can tackle the first by using the entoas mutation builder. Edit your ent/entc.go file and add the route description:

ent/entc.go
//go:build ignore

package main

import (
"log"

"entgo.io/contrib/entoas"
"entgo.io/ent/entc"
"entgo.io/ent/entc/gen"
"github.com/ariga/ogent"
"github.com/ogen-go/ogen"
)

func main() {
spec := new(ogen.Spec)
oas, err := entoas.NewExtension(
entoas.Spec(spec),
entoas.Mutations(func(_ *gen.Graph, spec *ogen.Spec) error {
spec.AddPathItem("/todos/{id}/done", ogen.NewPathItem().
SetDescription("Mark an item as done").
SetPatch(ogen.NewOperation().
SetOperationID("markDone").
SetSummary("Marks a todo item as done.").
AddTags("Todo").
AddResponse("204", ogen.NewResponse().SetDescription("Item marked as done")),
).
AddParameters(ogen.NewParameter().
InPath().
SetName("id").
SetRequired(true).
SetSchema(ogen.Int()),
),
)
return nil
}),
)
if err != nil {
log.Fatalf("creating entoas extension: %v", err)
}
ogent, err := ogent.NewExtension(spec)
if err != nil {
log.Fatalf("creating ogent extension: %v", err)
}
err = entc.Generate("./schema", &gen.Config{}, entc.Extensions(ogent, oas))
if err != nil {
log.Fatalf("running ent codegen: %v", err)
}
}

After running the code generator (go generate ./...) there should be a new entry in the ent/openapi.json file:

"/todos/{id}/done": {
"description": "Mark an item as done",
"patch": {
"tags": [
"Todo"
],
"summary": "Marks a todo item as done.",
"operationId": "markDone",
"responses": {
"204": {
"description": "Item marked as done"
}
}
},
"parameters": [
{
"name": "id",
"in": "path",
"schema": {
"type": "integer"
},
"required": true
}
]
}
Custom Endpoint

Custom Endpoint

The above mentioned ent/ogent/oas_server_gen.go file generated by ogen will reflect the changes as well:

ent/ogent/oas_server_gen.go
// Handler handles operations described by OpenAPI v3 specification.
type Handler interface {
// CreateTodo implements createTodo operation.
//
// Creates a new Todo and persists it to storage.
//
// POST /todos
CreateTodo(ctx context.Context, req CreateTodoReq) (CreateTodoRes, error)
// DeleteTodo implements deleteTodo operation.
//
// Deletes the Todo with the requested ID.
//
// DELETE /todos/{id}
DeleteTodo(ctx context.Context, params DeleteTodoParams) (DeleteTodoRes, error)
// ListTodo implements listTodo operation.
//
// List Todos.
//
// GET /todos
ListTodo(ctx context.Context, params ListTodoParams) (ListTodoRes, error)
// MarkDone implements markDone operation.
//
// PATCH /todos/{id}/done
MarkDone(ctx context.Context, params MarkDoneParams) (MarkDoneNoContent, error)
// ReadTodo implements readTodo operation.
//
// Finds the Todo with the requested ID and returns it.
//
// GET /todos/{id}
ReadTodo(ctx context.Context, params ReadTodoParams) (ReadTodoRes, error)
// UpdateTodo implements updateTodo operation.
//
// Updates a Todo and persists changes to storage.
//
// PATCH /todos/{id}
UpdateTodo(ctx context.Context, req UpdateTodoReq, params UpdateTodoParams) (UpdateTodoRes, error)
}

If you'd try to run the server now, the Go compiler will complain about it, because the ogent code generator does not know how to implement the new route. You have to do this by hand. Replace the current main.go with the following file to implement the new method.

main.go
package main

import (
"context"
"log"
"net/http"

"entgo.io/ent/dialect"
"entgo.io/ent/dialect/sql/schema"
"github.com/ariga/ogent/example/todo/ent"
"github.com/ariga/ogent/example/todo/ent/ogent"
_ "github.com/mattn/go-sqlite3"
)

type handler struct {
*ogent.OgentHandler
client *ent.Client
}

func (h handler) MarkDone(ctx context.Context, params ogent.MarkDoneParams) (ogent.MarkDoneNoContent, error) {
return ogent.MarkDoneNoContent{}, h.client.Todo.UpdateOneID(params.ID).SetDone(true).Exec(ctx)
}

func main() {
// Create ent client.
client, err := ent.Open(dialect.SQLite, "file:ent?mode=memory&cache=shared&_fk=1")
if err != nil {
log.Fatal(err)
}
// Run the migrations.
if err := client.Schema.Create(context.Background(), schema.WithAtlas(true)); err != nil {
log.Fatal(err)
}
// Create the handler.
h := handler{
OgentHandler: ogent.NewOgentHandler(client),
client: client,
}
// Start listening.
srv := ogent.NewServer(h)
if err := http.ListenAndServe(":8180", srv); err != nil {
log.Fatal(err)
}
}

If you restart your server you can then raise the following request to mark a todo item as done:

curl -X PATCH localhost:8180/todos/1/done

これから

There are some improvements planned for ogent, most notably a code generated, type-safe way to add filtering capabilities to the LIST routes. We want to hear your feedback first.

まとめ

In this post we announced ogent, the official implementation generator for entoas generated OpenAPI Specification documents. This extension uses the power of ogen, a very powerful and feature-rich Go code generator for OpenAPI v3 documents, to provide a ready-to-use, extensible server RESTful HTTP API servers.

Please note, that both ogen and entoas/ogent have not reached their first major release yet, and it is work in progress. Nevertheless, the API can be considered stable.

Have questions? Need help with getting started? Feel free to join our Discord server or Slack channel.

:::