Skip to content

Commit

Permalink
Added basic support for foreign keys, updated docs, dehardcoding
Browse files Browse the repository at this point in the history
  • Loading branch information
glothriel committed Feb 19, 2025
1 parent a7d9369 commit c6c5dbc
Show file tree
Hide file tree
Showing 28 changed files with 958 additions and 276 deletions.
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,6 @@ Other gunicorn flag variants had even lower performance.
* Add support for model relations
* ~~Add support for viewsets~~
* Add support for complex pagination implementations
* Add support for permissions (authorization)
* Add support for automatic OpenAPI spec generation

* Add support for output formatters
Expand Down
63 changes: 51 additions & 12 deletions docs/docs/models.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,28 +40,67 @@ The functions use reflection to convert between the types, so they are not the f

## Model fields

All the model fields must be tagged with a `json` tag, which is used to map the field name in `models.InternalValue`, and thus to (at least default) request and response JSON payloads. Saying that, GRF must know how to:

:::danger
* Read the field from the storage (in case of GORM, the field should implement the sql.Scanner interface)
* Write the field to the storage (in case of GORM, the field should implement the driver.Valuer interface)
* Read the field from the request JSON payload (GRF uses some tricks to do that, read more in the [Serializers](./serializers) section)
* Write the field to the response JSON payload, read more in the [Serializers](./serializers) section, like above.

Those types are not supported yet, but will be in the future:

* time.Time
* time.Duration
:::warning
Those types are not supported yet, but will be in the future:
* `slice<int>`
* pointer fields, for example `*string`
* JSON fields, as non-string, dedicated column types (eg. Postgres)

:::

All the model fields must be tagged with a `json` tag, which is used to map the field name in `models.InternalValue`, and thus to (at least default) request and response JSON payloads. Saying that, GRF must know how to:
### Slice field

* Read the field from the storage (in case of GORM, the field should implement the sql.Scanner interface)
* Write the field to the storage (in case of GORM, the field should implement the driver.Valuer interface)
* Read the field from the request JSON payload (GRF uses some tricks to do that, read more in the [Serializers](./serializers) section)
* Write the field to the response JSON payload, read more in the [Serializers](./serializers) section, like above.
`models.SliceField` can be used to store slices, that are encoded to JSON string for storage (implement sql.Scanner and driver.Valuer interfaces). In request and response JSON payloads, the slice is represented as a JSON array. The types of the slice need to be golang built-in basic types. The field provides validation of all the elements in the slice.

## Model relations

### Slice field
GRF models by themselves do not directly support relations, but:

* grf allows setting a `grf:"relation"` tag on the field, that instructs serializers to not treat a field as a basic type and skip initial parsing
* GORM's query driver `WithPreload` method can be used to [preload](https://gorm.io/docs/preload.html) related models
* `fields.SerializerField` can be used to include related models in the response JSON payload as a nested object

All of this together allows for a simple implementation of relations in GRF:

```go

`models.SliceField` can be used to store slices, that are encoded to JSON string for storage (implement sql.Scanner and driver.Valuer interfaces). In request and response JSON payloads, the slice is represented as a JSON array. The types of the slice need to be golang built-in types. The field provides validation of all the elements in the slice.
type Profile struct {
models.BaseModel
Name string `json:"name" gorm:"size:191;column:name"`
Photos []Photo `json:"photos" gorm:"foreignKey:profile_id" grf:"relation"`
}

type Photo struct {
models.BaseModel
ProfileID uuid.UUID `json:"profile_id" gorm:"size:191;column:profile_id"`
}

views.NewModelViewSet[Profile](
"/profiles",
queries.GORM[Profile](
gormDB,
).WithPreload(
"photos", // Note JSON tag here, in original GORM API it's the field name
).WithOrderBy(
"`profiles`.`created_at` ASC",
),
).WithSerializer(
serializers.NewModelSerializer[Profile]().WithNewField(
serializers.NewSerializerField[Photo](
"photos",
serializers.NewModelSerializer[Photo](),
),
),
).Register(router)
```

:::warning
GORM's Joins are not supported, as they are pretty useless anyway. If you need to join tables, you have no choice but to create a view in your SQL database and use it as a model.
:::
23 changes: 23 additions & 0 deletions docs/docs/query-drivers.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,29 @@ Driver configured in such way:
* Sorts the list of products by name in ascending order
* Uses limit/offset pagination provided by gorm query driver package

#### Transactions

All the default REST actions are performed in a single query, thus a transaction is not strictly needed. If however you'd like your action to have some side-effects (for example saving an entry in an audit log), you can use GORM query driver's transaction support.

```go
queryDriver.CRUD().WithCreate(
gormq.CreateTx(
gormq.BeforeCreate(
func(ctx *gin.Context, iv models.InternalValue, tx *gorm.DB) (models.InternalValue, error) {
// do whatever you want with tx before creating
return iv, nil
},
),
)(queryDriver.CRUD().Create),
)
```

The API is a little bit complex (with functions returning functions creating functions 🤣), so it may be changed at some point, but for now it does the job.

#### Relationships

GORM query driver supports basic relationships between models. See more in [model relations section](./models#model-relations).

### InMemory `queries.InMemory()`

InMemory query driver is a simple implementation of QueryDriver interface, that stores all the data in memory. It's useful for testing and prototyping, but it definetly should not be used in production. It doesn't support any filtering, sorting or pagination.
Expand Down
71 changes: 70 additions & 1 deletion docs/docs/serializers.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,72 @@
# Serializers

Nothing here yet...
Serializers in GRF are responsible for translation of data between the database and the API. They control which fields are accepted in the payload, which are served in the response, and how they are formatted. The best way to understand how serializers work is to see them in action.

```go
type Serializer interface {
// This function is called when transforming the request payload into an internal value
// that will be later passed to QueryDriver (for example GORM) for processing.
ToInternalValue(map[string]any, *gin.Context) (models.InternalValue, error)

// This function transforms data obtained from the QueryDriver into a response payload, that
// will be then marshalled into JSON and sent to the client.
ToRepresentation(models.InternalValue, *gin.Context) (Representation, error)
}
```

## ModelSerializer

`ModelSerializer` is the workhorse of GRF. It can be created with `serializers.NewModelSerializer[Model]()`. By default such serializer will include all struct fields (there's also `NewEmptyModelSerializer[Model]()` that will not include any fields).


### Using existing fields in ModelSerializer

You can select which existing fields should be included in the response by using the `WithModelFields` method.

```go
serializer := serializers.NewModelSerializer[Model]().WithModelFields("field1", "field2")
```

Please note, that the fields here are identified by their JSON tags, not the struct field names.

### Adding completely new fields

For adding fields, that are not present in the model struct or you didn't want to include in the `WithModelFields` method, you can use the `WithField` method. More about fields can be found in the [Fields](#fields) section.

```go
serializer := serializers.NewModelSerializer[Model]().WithNewField(
fields.NewField("color").ReadOnly().WithRepresentationFunc(
func(models.InternalValue, string, *gin.Context) (any, error) {
return "blue", nil
}
)
)
```

### Customizing existing fields

You can also customize existing fields by using the `WithField` method.

```go

serializer := serializers.NewModelSerializer[Model]().WithField(
"color",
func(oldField fields.Field){
return oldField.WithRepresentationFunc(
func(models.InternalValue, string, *gin.Context) (any, error) {
return "pink", nil
}
)
}
)
```

## Fields

Fields are used by ModelSerializers to transform data between the database and the API on the single JSON field / SQL column level. They can be created with `fields.NewField("field_name")`. The API is pretty straightforward, please consult the [godoc](https://pkg.go.dev/github.com/glothriel/grf/pkg/fields).

TLDR; you can:

* Set the field as read-only, write-only or read-write
* Set the InternalValue function, that will be used to transform the data from the API to format that can be stored in the database
* Set the Representation function, that will be used to transform the data from the database to the API response
16 changes: 9 additions & 7 deletions docs/docs/views.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,19 +29,22 @@ type Person struct {

func main() {
ginEngine := gin.Default()
// For example's sake, we'll use the in-memory query driver
queryDriver := queries.InMemory[Person]()
personViewSet := views.NewViewSet("/people", queryDriver).WithActions(views.ActionsList)
// NewModelViewSet creates a new ViewSet for the Person model, which uses
// NewModelSerializer under the hood
personViewSet := views.NewModelViewSet[Person](
"/people",
queryDriver,
// Here we can override REST actions, that are served by the ViewSet
).WithActions(views.ActionList)

// Register the ViewSet with your Gin engine
personViewSet.Register(ginEngine)

// Start your Gin server
ginEngine.Run(":8080")
}
```

In this example, we create a basic ViewSet for the `Person` model, and we register it with the Gin engine. The viewset will only respond to List action.

## Configuring Actions

ViewSets provide the following actions:
Expand Down Expand Up @@ -90,8 +93,6 @@ personViewSet.OnUpdate(customUpdateLogic)
personViewSet.OnDestroy(customDestroyLogic)
```

In this example, we add custom logic for the Create, Update, and Destroy operations.

## Registering the ViewSet

After configuring your ViewSet and Gin engine, make sure to call the `Register` method to register the ViewSet's routes:
Expand All @@ -110,6 +111,7 @@ It's possible to add a custom action for your ViewSet. This can be useful when y
views.NewViewSet[CustomerProfile](
"/me",
qd,
serializers.NewModelSerializer[CustomerProfile](),
).WithExtraAction(
views.NewExtraAction[CustomerProfile](
"GET",
Expand Down
9 changes: 5 additions & 4 deletions docs/docusaurus.config.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
// @ts-check
// Note: type annotations allow type checking and IDEs autocompletion

const lightCodeTheme = require('prism-react-renderer/themes/github');
const darkCodeTheme = require('prism-react-renderer/themes/dracula');
const {themes} = require('prism-react-renderer');
const lightTheme = themes.github;
const darkTheme = themes.dracula;

/** @type {import('@docusaurus/types').Config} */
const config = {
Expand Down Expand Up @@ -117,8 +118,8 @@ const config = {
copyright: `Copyright © ${new Date().getFullYear()} Gin REST Framework authors. Built with Docusaurus.`,
},
prism: {
theme: lightCodeTheme,
darkTheme: darkCodeTheme,
theme: lightTheme,
darkTheme: darkTheme,
},
}),
};
Expand Down
19 changes: 10 additions & 9 deletions docs/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "docs",
"version": "0.0.0",
"version": "1.0.0",
"private": true,
"scripts": {
"docusaurus": "docusaurus",
Expand All @@ -14,16 +14,17 @@
"write-heading-ids": "docusaurus write-heading-ids"
},
"dependencies": {
"@docusaurus/core": "2.4.1",
"@docusaurus/preset-classic": "2.4.1",
"@mdx-js/react": "^1.6.22",
"@docusaurus/core": "3.7.0",
"@docusaurus/preset-classic": "3.7.0",
"@mdx-js/react": "^3.0.0",
"clsx": "^1.2.1",
"prism-react-renderer": "^1.3.5",
"react": "^17.0.2",
"react-dom": "^17.0.2"
"prism-react-renderer": "^2.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@docusaurus/module-type-aliases": "2.4.1"
"@docusaurus/module-type-aliases": "3.7.0",
"@docusaurus/types": "3.7.0"
},
"browserslist": {
"production": [
Expand All @@ -38,6 +39,6 @@
]
},
"engines": {
"node": ">=16.14"
"node": ">=18.0"
}
}
1 change: 1 addition & 0 deletions docs/src/components/HomepageFeatures/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const FeatureList = [
description: (
<>
Hate code generation? So do we. GRF uses generics to hide the boring stuff and let you focus on what brings value to your project.

</>
),
},
Expand Down
61 changes: 36 additions & 25 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,52 +1,63 @@
module github.com/glothriel/grf

go 1.21
go 1.21.0

toolchain go1.23.6

require (
github.com/gin-gonic/gin v1.9.1
github.com/go-playground/assert/v2 v2.2.0
github.com/go-playground/validator/v10 v10.14.1
github.com/google/uuid v1.3.0
github.com/fergusstrange/embedded-postgres v1.30.0
github.com/gin-gonic/gin v1.10.0
github.com/go-playground/validator/v10 v10.25.0
github.com/google/uuid v1.6.0
github.com/mitchellh/mapstructure v1.5.0
github.com/santhosh-tekuri/jsonschema/v5 v5.3.1
github.com/shopspring/decimal v1.3.1
github.com/sirupsen/logrus v1.9.3
github.com/stretchr/testify v1.8.4
github.com/stretchr/testify v1.10.0
gorm.io/driver/postgres v1.5.11
gorm.io/driver/sqlite v1.5.2
gorm.io/gorm v1.25.2
gorm.io/gorm v1.25.12
)

require (
github.com/bytedance/sonic v1.9.2 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
github.com/bytedance/sonic v1.12.8 // indirect
github.com/bytedance/sonic/loader v0.2.3 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect
github.com/cloudwego/base64x v0.1.5 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gin-contrib/sse v1.0.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/pgx/v5 v5.5.5 // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.5 // indirect
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/mattn/go-sqlite3 v1.14.17 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lib/pq v1.10.9 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rogpeppe/go-internal v1.11.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/crypto v0.10.0 // indirect
golang.org/x/net v0.11.0 // indirect
golang.org/x/sys v0.9.0 // indirect
golang.org/x/text v0.10.0 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
golang.org/x/arch v0.14.0 // indirect
golang.org/x/crypto v0.33.0 // indirect
golang.org/x/net v0.35.0 // indirect
golang.org/x/sync v0.11.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.0 // indirect
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
google.golang.org/protobuf v1.31.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
google.golang.org/protobuf v1.36.5 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
Loading

0 comments on commit c6c5dbc

Please sign in to comment.