575 lines
17 KiB
Markdown
575 lines
17 KiB
Markdown
---
|
|
title: Auto generate REST CRUD with Ent and ogen
|
|
author: MasseElch
|
|
authorURL: "https://github.com/masseelch"
|
|
authorImageURL: "https://avatars.githubusercontent.com/u/12862103?v=4"
|
|
image: "https://entgo.io/images/assets/ogent/1.png"
|
|
---
|
|
|
|
In the end of 2021 we announced that [Ent](https://entgo.io) got a new official extension to generate a fully
|
|
compliant [OpenAPI Specification](https://swagger.io/resources/open-api/)
|
|
document: [`entoas`](https://github.com/ent/contrib/tree/master/entoas).
|
|
|
|
Today, we are very happy to announce that there is a new extension built to work
|
|
with `entoas`: [`ogent`](https://github.com/ariga/ogent). It utilizes the power
|
|
of [`ogen`](https://github.com/ogen-go/ogen) ([website](https://ogen.dev/docs/intro/)) to provide a type-safe,
|
|
reflection-free implementation of the OpenAPI Specification document generated by `entoas`.
|
|
|
|
`ogen` is an opinionated Go code generator for OpenAPI Specification v3 documents. `ogen` generates both server and
|
|
client implementations for a given OpenAPI Specification document. The only thing left to do for the user is to
|
|
implement an interface to access the data layer of any application. `ogen` has many cool features, one of which is
|
|
integration with [OpenTelemetry](https://opentelemetry.io/). Make sure to check it out and leave some love.
|
|
|
|
The extension presented in this post serves as a bridge between Ent and the code generated
|
|
by [`ogen`](https://github.com/ogen-go/ogen). It uses the configuration of `entoas` to generate the missing parts of
|
|
the `ogen` code.
|
|
|
|
The following diagram shows how Ent interacts with both the extensions `entoas` and `ogent` and how `ogen` is involved.
|
|
|
|
<div style={{textAlign: 'center'}}>
|
|
<img alt="Diagram" src="https://entgo.io/images/assets/ogent/1.png" />
|
|
<p style={{fontSize: 12}}>Diagram</p>
|
|
</div>
|
|
|
|
If you are new to Ent and want to learn more about it, how to connect to different types of databases, run migrations or
|
|
work with entities, then head over to the [Setup Tutorial](https://entgo.io/docs/tutorial-setup/)
|
|
|
|
The code in this post is available in the modules [examples](https://github.com/ariga/ogent/tree/main/example/todo).
|
|
|
|
### Getting Started
|
|
|
|
:::note
|
|
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](https://entgo.io/docs/code-gen#use-entc-as-a-package). First install both `entoas` and `ogent`
|
|
extensions to your Go module:
|
|
|
|
```shell
|
|
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:
|
|
|
|
```go title="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:
|
|
|
|
```go title="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!
|
|
|
|
### Generate a CRUD HTTP API Server
|
|
|
|
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:
|
|
|
|
```go title="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:
|
|
|
|
```shell
|
|
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:
|
|
|
|
```json title="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": {
|
|
[...]
|
|
```
|
|
|
|
<div style={{textAlign: 'center'}}>
|
|
<img alt="Swagger Editor Example" src="https://entgo.io/images/assets/ogent/2.png" />
|
|
<p style={{fontSize: 12}}>Swagger Editor Example</p>
|
|
</div>
|
|
|
|
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.
|
|
|
|
```go title="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](https://github.com/ent/contrib/entoas).
|
|
|
|
The following shows an example for a generated READ route:
|
|
|
|
```go
|
|
// 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
|
|
}
|
|
```
|
|
|
|
### Run the server
|
|
|
|
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`:
|
|
|
|
```go title="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:
|
|
|
|
```shell
|
|
↪ 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:
|
|
|
|
```shell
|
|
↪ 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:
|
|
|
|
```go {18} title="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:
|
|
|
|
```shell
|
|
go generate ./...
|
|
go run -mod=mod main.go
|
|
```
|
|
|
|
Now, if we attempt to create the Todo again, see what happens:
|
|
|
|
```shell
|
|
↪ 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`](https://github.com/ogen-go/ogen)
|
|
and [`ogent`](https://github.com/ariga/ogent) (**you really should!**), mark the todo as done by raising a PATCH
|
|
request:
|
|
|
|
```shell
|
|
↪ 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
|
|
}
|
|
```
|
|
|
|
### Add custom endpoints
|
|
|
|
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:
|
|
|
|
```go {17-37} title="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:
|
|
|
|
```json
|
|
"/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
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
<div style={{textAlign: 'center'}}>
|
|
<img alt="Custom Endpoint" src="https://entgo.io/images/assets/ogent/3.png" />
|
|
<p style={{fontSize: 12}}>Custom Endpoint</p>
|
|
</div>
|
|
|
|
The above mentioned `ent/ogent/oas_server_gen.go` file generated by `ogen` will reflect the changes as well:
|
|
|
|
```go {21-24} title="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.
|
|
|
|
```go {15-22,34-38,40} title="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:
|
|
|
|
```shell
|
|
↪ curl -X PATCH localhost:8180/todos/1/done
|
|
```
|
|
|
|
### Yet to come
|
|
|
|
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.
|
|
|
|
### Wrapping Up
|
|
|
|
In this post we announced `ogent`, the official implementation generator for `entoas` generated OpenAPI Specification
|
|
documents. This extension uses the power of [`ogen`](https://github.com/ogen-go/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](https://discord.gg/qZmPgTE6RX) or [Slack channel](https://entgo.io/docs/slack/).
|
|
|
|
:::note For more Ent news and updates:
|
|
|
|
- Subscribe to our [Newsletter](https://entgo.substack.com/)
|
|
- Follow us on [Twitter](https://twitter.com/entgo_io)
|
|
- Join us on #ent on the [Gophers Slack](https://entgo.io/docs/slack)
|
|
- Join us on the [Ent Discord Server](https://discord.gg/qZmPgTE6RX)
|
|
|
|
:::
|