From c6c5dbc5466b7b7d07a0561acb3f7e24deb791d7 Mon Sep 17 00:00:00 2001 From: Konstanty Karagiorgis Date: Wed, 19 Feb 2025 23:13:07 +0100 Subject: [PATCH] Added basic support for foreign keys, updated docs, dehardcoding --- README.md | 1 - docs/docs/models.md | 63 ++++- docs/docs/query-drivers.md | 23 ++ docs/docs/serializers.md | 71 +++++- docs/docs/views.md | 16 +- docs/docusaurus.config.js | 9 +- docs/package.json | 19 +- docs/src/components/HomepageFeatures/index.js | 1 + go.mod | 61 +++-- go.sum | 99 ++++++-- pkg/detectors/attributes.go | 32 ++- pkg/detectors/representation.go | 21 ++ pkg/examples/products/main.go | 8 +- pkg/examples/simple/main.go | 25 +- pkg/fields/field.go | 50 +++- pkg/fields/field_test.go | 22 +- .../{integration_test.go => fields_test.go} | 224 ++++++++++++------ pkg/integration/integration.go | 81 +++++-- pkg/integration/relations_test.go | 142 +++++++++++ pkg/models/tags.go | 43 ++++ pkg/queries/common/common.go | 14 ++ pkg/queries/gormq/gorm.go | 79 ++++-- pkg/serializers/field.go | 41 ++++ pkg/serializers/model.go | 21 +- pkg/serializers/model_test.go | 12 +- pkg/views/view.go | 2 +- pkg/views/viewsets.go | 48 ++-- pkg/views/viewsets_test.go | 6 +- 28 files changed, 958 insertions(+), 276 deletions(-) rename pkg/integration/{integration_test.go => fields_test.go} (66%) create mode 100644 pkg/integration/relations_test.go create mode 100644 pkg/models/tags.go create mode 100644 pkg/serializers/field.go diff --git a/README.md b/README.md index 5248107..6a368af 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/docs/models.md b/docs/docs/models.md index 16b1526..86fb927 100644 --- a/docs/docs/models.md +++ b/docs/docs/models.md @@ -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` * 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. +::: diff --git a/docs/docs/query-drivers.md b/docs/docs/query-drivers.md index f753c10..f1b2771 100644 --- a/docs/docs/query-drivers.md +++ b/docs/docs/query-drivers.md @@ -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. diff --git a/docs/docs/serializers.md b/docs/docs/serializers.md index c4688ad..2bd7923 100644 --- a/docs/docs/serializers.md +++ b/docs/docs/serializers.md @@ -1,3 +1,72 @@ # Serializers -Nothing here yet... \ No newline at end of file +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 \ No newline at end of file diff --git a/docs/docs/views.md b/docs/docs/views.md index 7947967..19d5fbe 100644 --- a/docs/docs/views.md +++ b/docs/docs/views.md @@ -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: @@ -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: @@ -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", diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js index dd2f483..cf53d0b 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -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 = { @@ -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, }, }), }; diff --git a/docs/package.json b/docs/package.json index 3b996cf..a3c3cbe 100644 --- a/docs/package.json +++ b/docs/package.json @@ -1,6 +1,6 @@ { "name": "docs", - "version": "0.0.0", + "version": "1.0.0", "private": true, "scripts": { "docusaurus": "docusaurus", @@ -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": [ @@ -38,6 +39,6 @@ ] }, "engines": { - "node": ">=16.14" + "node": ">=18.0" } } diff --git a/docs/src/components/HomepageFeatures/index.js b/docs/src/components/HomepageFeatures/index.js index 0160a3e..9acda23 100644 --- a/docs/src/components/HomepageFeatures/index.js +++ b/docs/src/components/HomepageFeatures/index.js @@ -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. + ), }, diff --git a/go.mod b/go.mod index dd806b3..0e7d669 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index 6e6703a..1d69609 100644 --- a/go.sum +++ b/go.sum @@ -1,19 +1,39 @@ github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= github.com/bytedance/sonic v1.9.2 h1:GDaNjuWSGu09guE9Oql0MSTNhNCLlWwO8y/xM5BzcbM= github.com/bytedance/sonic v1.9.2/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U= +github.com/bytedance/sonic v1.10.0-rc/go.mod h1:ElCzW+ufi8qKqNW0FY314xriJhyJhuoJ3gFZdAHF7NM= +github.com/bytedance/sonic v1.12.8 h1:4xYRVRlXIgvSZ4e8iVTlMF5szgpXd4AfvuWgA8I8lgs= +github.com/bytedance/sonic v1.12.8/go.mod h1:uVvFidNmlt9+wa31S1urfwwthTWteBgG0hWuoKAXTx8= +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/bytedance/sonic/loader v0.2.3 h1:yctD0Q3v2NOGfSWPLPvG2ggA2kV6TS6s4wioyEqssH0= +github.com/bytedance/sonic/loader v0.2.3/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams= github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= +github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d h1:77cEq6EriyTZ0g/qfRdp61a3Uu/AWrgIq2s0ClJV1g0= +github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d/go.mod h1:8EPpVsBuRksnlj1mLy4AWzRNQYxauNi62uWcE3to6eA= +github.com/chenzhuoyu/iasm v0.9.0/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog= +github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4= +github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fergusstrange/embedded-postgres v1.30.0 h1:ewv1e6bBlqOIYtgGgRcEnNDpfGlmfPxB8T3PO9tV68Q= +github.com/fergusstrange/embedded-postgres v1.30.0/go.mod h1:w0YvnCgf19o6tskInrOOACtnqfVlOvluz3hlNLY7tRk= github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= +github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= +github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E= +github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0= github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= +github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= +github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -22,14 +42,28 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.14.1 h1:9c50NUPC30zyuKprjL3vNZ0m5oG+jU0zvx4AqHGnv4k= github.com/go-playground/validator/v10 v10.14.1/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= +github.com/go-playground/validator/v10 v10.25.0 h1:5Dh7cjvzR7BRZadnsVOzPhWsrwUr0nmsZJxEAnFLNO8= +github.com/go-playground/validator/v10 v10.25.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw= +github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= @@ -39,19 +73,25 @@ github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHm github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg= github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= -github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY= +github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM= -github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -61,6 +101,8 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ= github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4= +github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= +github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -76,6 +118,7 @@ github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVs github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= @@ -83,40 +126,68 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo= +github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k= golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= -golang.org/x/crypto v0.10.0 h1:LKqV2xt9+kDzSTfOhx4FrkEBcMrAgHSYgzywV9zcGmM= -golang.org/x/crypto v0.10.0/go.mod h1:o4eNf7Ede1fv+hwOwZsTHl9EsPFO6q6ZvYR8vYfY45I= +golang.org/x/arch v0.14.0 h1:z9JUEZWr8x4rR0OU6c4/4t6E6jOZ8/QBS2bBYBm4tx4= +golang.org/x/arch v0.14.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= +golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= +golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= golang.org/x/net v0.11.0 h1:Gi2tvZIJyBtO9SDr1q9h5hEQCp/4L2RQ+ar0qjx2oNU= golang.org/x/net v0.11.0/go.mod h1:2L/ixqYpgIVXmeoSA/4Lu7BzTG4KIyPIryS4IsOd1oQ= +golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= +golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= +golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= +golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.9.0 h1:KS/R3tvhPqvJvwcKfnBHJwwthS11LRhmM5D59eEXa0s= -golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/text v0.10.0 h1:UpjohKhiEgNc0CSauXmwYftY1+LlaC75SJwh0SgCX58= -golang.org/x/text v0.10.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= +google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314= +gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI= gorm.io/driver/sqlite v1.5.2 h1:TpQ+/dqCY4uCigCFyrfnrJnrW9zjpelWVoEVNy5qJkc= gorm.io/driver/sqlite v1.5.2/go.mod h1:qxAuCol+2r6PannQDpOP1FP6ag3mKi4esLnB/jHed+4= -gorm.io/gorm v1.25.2 h1:gs1o6Vsa+oVKG/a9ElL3XgyGfghFfkKA2SInQaCyMho= -gorm.io/gorm v1.25.2/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k= +gorm.io/gorm v1.25.10 h1:dQpO+33KalOA+aFYGlK+EfxcI5MbO7EP2yYygwh9h+s= +gorm.io/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= +gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8= +gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/pkg/detectors/attributes.go b/pkg/detectors/attributes.go index 247332b..893547a 100644 --- a/pkg/detectors/attributes.go +++ b/pkg/detectors/attributes.go @@ -3,10 +3,10 @@ package detectors import ( "database/sql" "encoding" - "fmt" "reflect" "github.com/glothriel/grf/pkg/fields" + "github.com/glothriel/grf/pkg/models" ) // Prints a summary with the fields of the model obtained using reflection @@ -23,6 +23,20 @@ func FieldTypes[Model any]() map[string]string { return ret } +// Prints a summary with the fields of the model obtained using reflection +func FieldNames[Model any]() map[string]string { + ret := make(map[string]string) + + var m Model + fields := reflect.VisibleFields(reflect.TypeOf(m)) + for _, field := range fields { + if !field.Anonymous { + ret[field.Tag.Get("json")] = field.Name + } + } + return ret +} + func Fields[Model any]() []string { fieldNames := []string{} var m Model @@ -50,19 +64,14 @@ type fieldSettings struct { func getFieldSettings[Model any](fieldName string) *fieldSettings { var entity Model var settings *fieldSettings - fieldTypes := FieldTypes[Model]() for _, field := range reflect.VisibleFields(reflect.TypeOf(entity)) { jsonTag := field.Tag.Get("json") if jsonTag == fieldName { var theTypeAsAny any reflectedInstance := reflect.New(reflect.TypeOf(reflect.ValueOf(entity).FieldByName(field.Name).Interface())).Elem() - isForeignKey := false - if _, ok := fieldTypes[fmt.Sprintf( - "%s_id", jsonTag, - )]; ok { - isForeignKey = true - } + settingsFromTag := models.ParseTag(field) + _, fieldMarkedAsRelation := settingsFromTag[models.TagIsRelation] if reflectedInstance.CanAddr() { theTypeAsAny = reflectedInstance.Addr().Interface() @@ -74,7 +83,7 @@ func getFieldSettings[Model any](fieldName string) *fieldSettings { _, isEncodingTextUnmarshaler := theTypeAsAny.(encoding.TextUnmarshaler) _, isGRFRepresentable := theTypeAsAny.(fields.GRFRepresentable) _, isGRFParsable := theTypeAsAny.(fields.GRFParsable) - _, isSqlNull32 := theTypeAsAny.(*sql.NullInt32) + _, isSQLNull32 := theTypeAsAny.(*sql.NullInt32) settings = &fieldSettings{ itsType: reflect.TypeOf( @@ -84,9 +93,8 @@ func getFieldSettings[Model any](fieldName string) *fieldSettings { isEncodingTextUnmarshaler: isEncodingTextUnmarshaler, isGRFRepresentable: isGRFRepresentable, isGRFParsable: isGRFParsable, - isForeignKey: isForeignKey, - - isSqlNullInt32: isSqlNull32, + isForeignKey: fieldMarkedAsRelation, + isSqlNullInt32: isSQLNull32, } } } diff --git a/pkg/detectors/representation.go b/pkg/detectors/representation.go index 8527460..af06a80 100644 --- a/pkg/detectors/representation.go +++ b/pkg/detectors/representation.go @@ -5,6 +5,7 @@ import ( "encoding" "fmt" "reflect" + "time" "github.com/gin-gonic/gin" "github.com/glothriel/grf/pkg/fields" @@ -24,6 +25,7 @@ func DefaultToRepresentationDetector[Model any]() ToRepresentationDetector[Model representationChild: &chainingToRepresentationDetector[Model]{ children: []ToRepresentationDetector[Model]{ &usingGRFRepresentableToRepresentationProvider[Model]{}, + &timeTimeToRepresentationProvider[Model]{}, &fromTypeMapperToRepresentationProvider[Model]{ mapper: types.Mapper(), modelTypeNames: FieldTypes[Model](), @@ -122,6 +124,25 @@ func (p usingGRFRepresentableToRepresentationProvider[Model]) ToRepresentation(f return nil, fmt.Errorf("Field `%s` is not a GRFRepresentable", fieldName) } +type timeTimeToRepresentationProvider[Model any] struct{} + +func (p timeTimeToRepresentationProvider[Model]) ToRepresentation(fieldName string) (fields.RepresentationFunc, error) { + + fieldSettings := getFieldSettings[Model](fieldName) + if fieldSettings.itsType.Name() == "Time" && fieldSettings.itsType.PkgPath() == "time" { + return ConvertFuncToRepresentationFuncAdapter( + func(v any) (any, error) { + vAsTime, ok := v.(time.Time) + if ok { + return vAsTime.Format("2006-01-02T15:04:05Z"), nil + } + return nil, fmt.Errorf("Field `%s` is not a time.Time", fieldName) + }, + ), nil + } + return nil, fmt.Errorf("Field `%s` is not a time.Time", fieldName) +} + type usingSqlNullFieldToRepresentationProvider[Model any, sqlNullType any] struct { valueFunc func(sqlNullType) any } diff --git a/pkg/examples/products/main.go b/pkg/examples/products/main.go index ba35199..7494278 100644 --- a/pkg/examples/products/main.go +++ b/pkg/examples/products/main.go @@ -100,6 +100,7 @@ func main() { views.NewViewSet[Category]( "/categories", queries.GORM[Category](gormDB).WithOrderBy("name ASC"), + serializers.NewModelSerializer[Category](), ).WithActions( views.ActionList, views.ActionCreate, ).WithSerializer( @@ -129,8 +130,8 @@ func main() { serializers.NewValidatingSerializer[Photo]( serializers.NewModelSerializer[Photo]().WithField( "product_id", - func(oldField *fields.Field[Photo]) { - oldField.WriteOnly().WithInternalValueFunc( + func(oldField fields.Field) { + oldField.WithWriteOnly().WithInternalValueFunc( func(m map[string]any, s string, ctx *gin.Context) (any, error) { return ctx.Param("product_id"), nil }, @@ -182,6 +183,7 @@ func main() { views.NewViewSet[CustomerProfile]( "/me", meQD, + serializers.NewModelSerializer[CustomerProfile](), ).WithExtraAction( views.NewExtraAction[CustomerProfile]( "GET", @@ -200,7 +202,7 @@ func main() { []string{"email", "last_name", "first_name"}, ).WithField( "email", - func(oldField *fields.Field[CustomerProfile]) { + func(oldField fields.Field) { oldField.WithInternalValueFunc( func(m map[string]any, s string, ctx *gin.Context) (any, error) { email := ctx.Request.Header.Get("X-User-Email") diff --git a/pkg/examples/simple/main.go b/pkg/examples/simple/main.go index 8f4c20e..045131c 100644 --- a/pkg/examples/simple/main.go +++ b/pkg/examples/simple/main.go @@ -1,30 +1,25 @@ package main import ( - "log" - "github.com/gin-gonic/gin" "github.com/glothriel/grf/pkg/queries" - "github.com/glothriel/grf/pkg/serializers" "github.com/glothriel/grf/pkg/views" ) type Person struct { - ID uint `json:"id" gorm:"primaryKey;autoIncrement"` - Name string `json:"name" gorm:"size:191;column:name"` + ID uint `json:"id"` + Name string `json:"name"` } func main() { ginEngine := gin.Default() queryDriver := queries.InMemory[Person]() - serializer := serializers.NewValidatingSerializer[Person]( - serializers.NewModelSerializer[Person](), - serializers.NewGoPlaygroundValidator[Person]( - map[string]any{ - "name": "required", - }, - ), - ) - views.NewModelViewSet[Person]("/people", queryDriver).WithSerializer(serializer).Register(ginEngine) - log.Fatal(ginEngine.Run(":8080")) + personViewSet := views.NewModelViewSet[Person]( + "/people", queryDriver).WithActions(views.ActionList) + + // Register the ViewSet with your Gin engine + personViewSet.Register(ginEngine) + + // Start your Gin server + ginEngine.Run(":8080") } diff --git a/pkg/fields/field.go b/pkg/fields/field.go index 326e2e8..ee873cf 100644 --- a/pkg/fields/field.go +++ b/pkg/fields/field.go @@ -20,7 +20,23 @@ func NewErrorFieldIsNotPresentInPayload(name string) ErrorFieldIsNotPresentInPay return ErrorFieldIsNotPresentInPayload{name: name} } -type Field[Model any] struct { +type Field interface { + Name() string + ToRepresentation(models.InternalValue, *gin.Context) (any, error) + ToInternalValue(map[string]any, *gin.Context) (any, error) + + IsReadable() bool + IsWritable() bool + + WithReadOnly() Field + WithWriteOnly() Field + WithReadWrite() Field + + WithRepresentationFunc(RepresentationFunc) Field + WithInternalValueFunc(InternalValueFunc) Field +} + +type ConcreteField[Model any] struct { name string representationFunc RepresentationFunc internalValueFunc InternalValueFunc @@ -29,48 +45,56 @@ type Field[Model any] struct { Writable bool } -func (s *Field[Model]) Name() string { +func (s *ConcreteField[Model]) Name() string { return s.name } -func (s *Field[Model]) ToRepresentation(intVal models.InternalValue, ctx *gin.Context) (any, error) { +func (s *ConcreteField[Model]) ToRepresentation(intVal models.InternalValue, ctx *gin.Context) (any, error) { return s.representationFunc(intVal, s.name, ctx) } -func (s *Field[Model]) ToInternalValue(reprModel map[string]any, ctx *gin.Context) (any, error) { +func (s *ConcreteField[Model]) ToInternalValue(reprModel map[string]any, ctx *gin.Context) (any, error) { return s.internalValueFunc(reprModel, s.name, ctx) } -func (s *Field[Model]) ReadOnly() *Field[Model] { +func (s *ConcreteField[Model]) WithReadOnly() Field { s.Readable = true s.Writable = false return s } -func (s *Field[Model]) WriteOnly() *Field[Model] { +func (s *ConcreteField[Model]) WithWriteOnly() Field { s.Readable = false s.Writable = true return s } -func (s *Field[Model]) ReadWrite() *Field[Model] { +func (s *ConcreteField[Model]) WithReadWrite() Field { s.Readable = true s.Writable = true return s } -func (s *Field[Model]) WithRepresentationFunc(f RepresentationFunc) *Field[Model] { +func (s *ConcreteField[Model]) IsReadable() bool { + return s.Readable +} + +func (s *ConcreteField[Model]) IsWritable() bool { + return s.Writable +} + +func (s *ConcreteField[Model]) WithRepresentationFunc(f RepresentationFunc) Field { s.representationFunc = f return s } -func (s *Field[Model]) WithInternalValueFunc(f InternalValueFunc) *Field[Model] { +func (s *ConcreteField[Model]) WithInternalValueFunc(f InternalValueFunc) Field { s.internalValueFunc = f return s } -func NewField[Model any](name string) *Field[Model] { - return &Field[Model]{ +func NewField[Model any](name string) Field { + return &ConcreteField[Model]{ name: name, representationFunc: func(intVal models.InternalValue, name string, ctx *gin.Context) (any, error) { return intVal[name], nil @@ -83,8 +107,8 @@ func NewField[Model any](name string) *Field[Model] { } } -func StaticValue[Model any](v any) func(oldField *Field[Model]) { - return func(oldField *Field[Model]) { +func StaticValue[Model any](v any) func(oldField Field) { + return func(oldField Field) { oldField.WithInternalValueFunc( func(m map[string]any, s string, ctx *gin.Context) (any, error) { return v, nil diff --git a/pkg/fields/field_test.go b/pkg/fields/field_test.go index 538447b..8ea84a3 100644 --- a/pkg/fields/field_test.go +++ b/pkg/fields/field_test.go @@ -36,7 +36,7 @@ func (m *fieldFuncsMocks) toInternalValueMock(reprModel map[string]any, name str func TestFieldName(t *testing.T) { // given - field := Field[struct{}]{ + field := ConcreteField[struct{}]{ name: "test", } @@ -50,7 +50,7 @@ func TestFieldName(t *testing.T) { func TestFieldToRepresentation(t *testing.T) { // given mocks := &fieldFuncsMocks{} - field := Field[struct{}]{ + field := ConcreteField[struct{}]{ name: "foo", representationFunc: mocks.toRepresentationMock, } @@ -69,7 +69,7 @@ func TestFieldToRepresentation(t *testing.T) { func TestFieldToInternalValue(t *testing.T) { // given mocks := &fieldFuncsMocks{} - field := Field[struct{}]{ + field := ConcreteField[struct{}]{ name: "foo", internalValueFunc: mocks.toInternalValueMock, } @@ -87,10 +87,10 @@ func TestFieldToInternalValue(t *testing.T) { func TestFieldReadOnly(t *testing.T) { // given - field := Field[struct{}]{} + field := ConcreteField[struct{}]{} // when - field.ReadOnly() + field.WithReadOnly() // then assert.True(t, field.Readable) @@ -99,10 +99,10 @@ func TestFieldReadOnly(t *testing.T) { func TestFieldWriteOnly(t *testing.T) { // given - field := Field[struct{}]{} + field := ConcreteField[struct{}]{} // when - field.WriteOnly() + field.WithWriteOnly() // then assert.False(t, field.Readable) @@ -111,10 +111,10 @@ func TestFieldWriteOnly(t *testing.T) { func TestFieldReadWrite(t *testing.T) { // given - field := Field[struct{}]{} + field := ConcreteField[struct{}]{} // when - field.ReadWrite() + field.WithReadWrite() // then assert.True(t, field.Readable) @@ -123,7 +123,7 @@ func TestFieldReadWrite(t *testing.T) { func TestFieldWithRepresentationFunc(t *testing.T) { // given - field := Field[struct{}]{} + field := ConcreteField[struct{}]{} funcCalls := 0 f := func(models.InternalValue, string, *gin.Context) (any, error) { funcCalls++ @@ -140,7 +140,7 @@ func TestFieldWithRepresentationFunc(t *testing.T) { func TestFieldWithInternalValueFunc(t *testing.T) { // given - field := Field[struct{}]{} + field := ConcreteField[struct{}]{} funcCalls := 0 f := func(map[string]any, string, *gin.Context) (any, error) { funcCalls++ diff --git a/pkg/integration/integration_test.go b/pkg/integration/fields_test.go similarity index 66% rename from pkg/integration/integration_test.go rename to pkg/integration/fields_test.go index 7e36eb7..d9d464f 100644 --- a/pkg/integration/integration_test.go +++ b/pkg/integration/fields_test.go @@ -4,15 +4,96 @@ import ( "database/sql" "fmt" "net/http" + "strings" "testing" "time" + embeddedpostgres "github.com/fergusstrange/embedded-postgres" "github.com/gin-gonic/gin" "github.com/glothriel/grf/pkg/models" "github.com/shopspring/decimal" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + "gorm.io/driver/postgres" + "gorm.io/driver/sqlite" + "gorm.io/gorm" ) -func TestTypes(t *testing.T) { +func TestSQLite(t *testing.T) { + DoTestTypes(t, sqlite.Open(":memory:")) +} + +type BoolModel struct { + models.BaseModel + Value bool `json:"value" gorm:"column:value"` +} + +type StringModel struct { + models.BaseModel + Value string `json:"value" gorm:"column:value"` +} + +type IntModel struct { + models.BaseModel + Value int `json:"value" gorm:"column:value"` +} + +type UintModel struct { + models.BaseModel + Value uint `json:"value" gorm:"column:value"` +} + +type FloatModel struct { + models.BaseModel + Value float64 `json:"value" gorm:"column:value"` +} + +type StringSliceModel struct { + models.BaseModel + Value models.SliceField[string] `json:"value" gorm:"column:value;type:json"` +} + +type FloatSliceModel struct { + models.BaseModel + Value models.SliceField[float64] `json:"value" gorm:"column:value;type:json"` +} + +type TimeModel struct { + models.BaseModel + Value time.Time `json:"value" gorm:"column:value;type:timestamp"` +} + +type BoolSliceModel struct { + models.BaseModel + Value models.SliceField[bool] `json:"value" gorm:"column:value;type:json"` +} + +type AnySliceModel struct { + models.BaseModel + Value models.SliceField[any] `json:"value" gorm:"column:value;type:json"` +} + +type NullBoolModel struct { + models.BaseModel + Value sql.NullBool `json:"value" gorm:"column:value"` +} + +type NullStringModel struct { + models.BaseModel + Value sql.NullString `json:"value" gorm:"column:value"` +} + +type DecimalModel struct { + models.BaseModel + Value decimal.Decimal `json:"value" gorm:"column:value"` +} + +type NullFloat64Model struct { + models.BaseModel + Value sql.NullFloat64 `json:"value" gorm:"column:value"` +} + +func DoTestTypes(t *testing.T, dialector gorm.Dialector) { // nolint: funlen tests := []struct { name string baseURL string @@ -43,10 +124,7 @@ func TestTypes(t *testing.T) { {"value": "True"}, }, router: func() *gin.Engine { - return registerModel[struct { - models.BaseModel - Value bool `json:"value" gorm:"column:value"` - }]("/bool_field") + return registerModel[BoolModel]("/bool_field", dialector) }, }, { @@ -70,10 +148,7 @@ func TestTypes(t *testing.T) { {"value": false}, }, router: func() *gin.Engine { - return registerModel[struct { - models.BaseModel - Value string `json:"value" gorm:"column:value"` - }]("/string_field") + return registerModel[StringModel]("/string_field", dialector) }, }, { @@ -99,10 +174,7 @@ func TestTypes(t *testing.T) { {"value": "hello world"}, }, router: func() *gin.Engine { - return registerModel[struct { - models.BaseModel - Value int `json:"value" gorm:"column:value"` - }]("/int_field") + return registerModel[IntModel]("/int_field", dialector) }, }, { @@ -127,10 +199,7 @@ func TestTypes(t *testing.T) { {"value": "hello world"}, }, router: func() *gin.Engine { - return registerModel[struct { - models.BaseModel - Value uint `json:"value" gorm:"column:value"` - }]("/uint_field") + return registerModel[UintModel]("/uint_field", dialector) }, }, { @@ -153,10 +222,7 @@ func TestTypes(t *testing.T) { {"value": "hello world"}, }, router: func() *gin.Engine { - return registerModel[struct { - models.BaseModel - Value float64 `json:"value" gorm:"column:value"` - }]("/float_field") + return registerModel[FloatModel]("/float_field", dialector) }, }, { @@ -179,10 +245,7 @@ func TestTypes(t *testing.T) { {"value": false}, }, router: func() *gin.Engine { - return registerModel[struct { - models.BaseModel - Value models.SliceField[string] `json:"value" gorm:"column:value;type:json"` - }]("/string_slice_field") + return registerModel[StringSliceModel]("/string_slice_field", dialector) }, }, { @@ -206,15 +269,12 @@ func TestTypes(t *testing.T) { {"value": false}, }, router: func() *gin.Engine { - return registerModel[struct { - models.BaseModel - Value models.SliceField[float64] `json:"value" gorm:"column:value;type:json"` - }]("/float_slice_field") + return registerModel[FloatSliceModel]("/float_slice_field", dialector) }, }, { name: "time.Time type", - baseURL: "/time", + baseURL: "/time_field", okBodies: []map[string]any{ {"value": "2021-01-01T00:00:00Z"}, {"value": "2021-01-01T00:00:00+00:00"}, @@ -240,10 +300,7 @@ func TestTypes(t *testing.T) { {"value": nil}, }, router: func() *gin.Engine { - return registerModel[struct { - models.BaseModel - Value time.Time `json:"value" gorm:"column:value"` - }]("/time") + return registerModel[TimeModel]("/time_field", dialector) }, }, { @@ -266,10 +323,7 @@ func TestTypes(t *testing.T) { {"value": false}, }, router: func() *gin.Engine { - return registerModel[struct { - models.BaseModel - Value models.SliceField[bool] `json:"value" gorm:"column:value;type:json"` - }]("/bool_slice_field") + return registerModel[BoolSliceModel]("/bool_slice_field", dialector) }, }, { @@ -306,10 +360,7 @@ func TestTypes(t *testing.T) { {"value": 1.23}, }, router: func() *gin.Engine { - return registerModel[struct { - models.BaseModel - Value models.SliceField[any] `json:"value" gorm:"column:value;type:json"` - }]("/any_slice_field") + return registerModel[AnySliceModel]("/any_slice_field", dialector) }, }, { @@ -324,10 +375,7 @@ func TestTypes(t *testing.T) { {"value": nil}, }, router: func() *gin.Engine { - return registerModel[struct { - models.BaseModel - Value sql.NullBool `json:"value" gorm:"column:value"` - }]("/null_bool_field") + return registerModel[NullBoolModel]("/null_bool_field", dialector) }, }, { @@ -342,10 +390,7 @@ func TestTypes(t *testing.T) { {"value": nil}, }, router: func() *gin.Engine { - return registerModel[struct { - models.BaseModel - Value sql.NullString `json:"value" gorm:"column:value"` - }]("/null_string_field") + return registerModel[NullStringModel]("/null_string_field", dialector) }, }, { @@ -375,10 +420,7 @@ func TestTypes(t *testing.T) { {"value": "1.3.37"}, }, router: func() *gin.Engine { - return registerModel[struct { - models.BaseModel - Value decimal.Decimal `json:"value" gorm:"column:value"` - }]("/decimal_field") + return registerModel[DecimalModel]("/decimal_field", dialector) }, }, { @@ -393,14 +435,10 @@ func TestTypes(t *testing.T) { {"value": nil}, }, router: func() *gin.Engine { - return registerModel[struct { - models.BaseModel - Value sql.NullFloat64 `json:"value" gorm:"column:value"` - }]("/null_float64_field") + return registerModel[NullFloat64Model]("/null_float64_field", dialector) }, }, } - for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -409,11 +447,11 @@ func TestTypes(t *testing.T) { } for _, errorBody := range tt.errorBodies { - NewAssertedReq( + newRequestTestCase( t, fmt.Sprintf("%s error: %v", tt.name, errorBody), ).Req( - NewRequest("POST", tt.baseURL, errorBody), + newRequest("POST", tt.baseURL, errorBody), ).ExCode( http.StatusBadRequest, ).Run(tt.router()) @@ -421,60 +459,102 @@ func TestTypes(t *testing.T) { for i, okBody := range tt.okBodies { router := tt.router() - resourceID := NewAssertedReq( + resourceID := newRequestTestCase( t, fmt.Sprintf("%s create", tt.name), ).Req( - NewRequest("POST", tt.baseURL, okBody), + newRequest("POST", tt.baseURL, okBody), ).ExCode( http.StatusCreated, ).ExJson( tt.okResponses[i], ).Run(router) - NewAssertedReq( + newRequestTestCase( t, tt.name, ).Req( - NewRequest("GET", tt.baseURL, nil), + newRequest("GET", tt.baseURL, nil), ).ExCode( http.StatusOK, ).ExJson( []any{tt.okResponses[i]}, ).Run(router) - NewAssertedReq( + newRequestTestCase( t, tt.name, ).Req( - NewRequest("GET", fmt.Sprintf("%s/%s", tt.baseURL, resourceID), nil), + newRequest("GET", fmt.Sprintf("%s/%s", tt.baseURL, resourceID), nil), ).ExCode( http.StatusOK, ).ExJson( tt.okResponses[i], ).Run(router) - NewAssertedReq( + newRequestTestCase( t, tt.name, ).Req( - NewRequest("DELETE", fmt.Sprintf("%s/%s", tt.baseURL, resourceID), nil), + newRequest("DELETE", fmt.Sprintf("%s/%s", tt.baseURL, resourceID), nil), ).ExCode( http.StatusNoContent, ).Run(router) - NewAssertedReq( + newRequestTestCase( t, tt.name, ).Req( - NewRequest("GET", tt.baseURL, nil), + newRequest("GET", tt.baseURL, nil), ).ExCode( http.StatusOK, ).ExJson( []any{}, ).Run(router) } - }) } } + +type PostgresTestSuite struct { + suite.Suite + postgres *embeddedpostgres.EmbeddedPostgres + DSN string +} + +func TestPostgres(t *testing.T) { + suite.Run(t, new(PostgresTestSuite)) +} + +func (s *PostgresTestSuite) SetupSuite() { + s.postgres = embeddedpostgres.NewDatabase() + startErr := s.postgres.Start() + if startErr == nil || strings.Contains(startErr.Error(), "process already listening") { + return + } + require.NoError(s.T(), startErr) +} + +func (s *PostgresTestSuite) TearDownSuite() { + stopErr := s.postgres.Stop() + if stopErr == nil || strings.Contains(stopErr.Error(), "server has not been started") { + return + } + require.NoError(s.T(), stopErr) +} + +func (s *PostgresTestSuite) SetupTest() { + db, connectErr := sql.Open( + "postgres", "host=localhost port=5432 user=postgres password=postgres dbname=postgres sslmode=disable", + ) + require.NoError(s.T(), connectErr) + _, dropErr := db.Exec("DROP DATABASE IF EXISTS tests") + require.NoError(s.T(), dropErr) + _, createErr := db.Exec("CREATE DATABASE tests") + require.NoError(s.T(), createErr) + s.DSN = "host=localhost port=5432 user=postgres password=postgres dbname=tests sslmode=disable TimeZone=UTC" +} + +func (s *PostgresTestSuite) TestPostgres() { + DoTestTypes(s.T(), postgres.Open(s.DSN)) +} diff --git a/pkg/integration/integration.go b/pkg/integration/integration.go index edca48f..143dea6 100644 --- a/pkg/integration/integration.go +++ b/pkg/integration/integration.go @@ -7,6 +7,7 @@ import ( "io" "net/http" "net/http/httptest" + "sync" "testing" "github.com/gin-gonic/gin" @@ -14,29 +15,43 @@ import ( "github.com/glothriel/grf/pkg/views" "github.com/sirupsen/logrus" "github.com/stretchr/testify/require" - "gorm.io/driver/sqlite" "gorm.io/gorm" ) +var gormDBs sync.Map + func registerModel[Model any]( - prefix string, + prefix string, dialector gorm.Dialector, registeredModels ...any, ) *gin.Engine { gin.SetMode(gin.ReleaseMode) router := gin.New() - gormDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) - if err != nil { - panic("failed to connect database") + + gDB, ok := gormDBs.Load(dialector.Name()) + if !ok { + theDB, err := gorm.Open(dialector, &gorm.Config{}) + if err != nil { + panic("failed to connect database") + } + gDB = any(theDB) + gormDBs.Store(dialector.Name(), gDB) + } + + gormDB, ok := gDB.(*gorm.DB) + if !ok { + panic("failed to cast database") } + views.NewModelViewSet[Model](prefix, queries.GORM[Model](gormDB).WithOrderBy(fmt.Sprintf("%s ASC", "created_at"))).Register(router) var entity Model - if migrateErr := gormDB.AutoMigrate(&entity); migrateErr != nil { + registeredModels = append(registeredModels, entity) + if migrateErr := gormDB.AutoMigrate(registeredModels...); migrateErr != nil { logrus.Fatalf("Error migrating database: %s", migrateErr) } return router } -func NewRequest(method, url string, body map[string]any) *http.Request { +func newRequest(method, url string, body map[string]any) *http.Request { req, _ := http.NewRequest(method, url, nil) if body != nil { req.Header.Set("Content-Type", "application/json") @@ -49,7 +64,7 @@ func NewRequest(method, url string, body map[string]any) *http.Request { return req } -type TestCase struct { +type requestTestCase struct { name string t *testing.T req *http.Request @@ -57,40 +72,56 @@ type TestCase struct { expectedJSON any } -func (tc *TestCase) Req(r *http.Request) *TestCase { +func (tc *requestTestCase) Req(r *http.Request) *requestTestCase { tc.req = r return tc } -func (tc *TestCase) ExCode(c int) *TestCase { +func (tc *requestTestCase) ExCode(c int) *requestTestCase { tc.expectedCode = c return tc } -func (tc *TestCase) ExJson(j any) *TestCase { +func (tc *requestTestCase) ExJson(j any) *requestTestCase { tc.expectedJSON = j return tc } func stripFields(v any, fields []string) any { - vMap, ok := v.(map[string]any) - if ok { - for _, field := range fields { - delete(vMap, field) + switch val := v.(type) { + case map[string]any: + // Create a new map to avoid modifying the original + result := make(map[string]any) + for k, v := range val { + if !contains(fields, k) { + // Recursively strip fields from nested values + result[k] = stripFields(v, fields) + } + } + return result + case []any: + // Create a new slice to avoid modifying the original + result := make([]any, len(val)) + for i, item := range val { + // Recursively strip fields from slice elements + result[i] = stripFields(item, fields) } - return vMap + return result + default: + return v } - vSlice, ok := v.([]any) - if ok { - for i, item := range vSlice { - vSlice[i] = stripFields(item, fields) +} + +func contains(slice []string, str string) bool { + for _, s := range slice { + if s == str { + return true } - return vSlice } - return v + return false } -func (tc *TestCase) Run(router *gin.Engine) string { +func (tc *requestTestCase) Run(router *gin.Engine) string { var theID string w := httptest.NewRecorder() router.ServeHTTP(w, tc.req) @@ -115,8 +146,8 @@ func (tc *TestCase) Run(router *gin.Engine) string { return theID } -func NewAssertedReq(t *testing.T, name string) *TestCase { - return &TestCase{ +func newRequestTestCase(t *testing.T, name string) *requestTestCase { + return &requestTestCase{ t: t, name: name, } diff --git a/pkg/integration/relations_test.go b/pkg/integration/relations_test.go new file mode 100644 index 0000000..513fff8 --- /dev/null +++ b/pkg/integration/relations_test.go @@ -0,0 +1,142 @@ +package integration + +import ( + "fmt" + "net/http" + "testing" + + "github.com/gin-gonic/gin" + "github.com/glothriel/grf/pkg/models" + "github.com/glothriel/grf/pkg/queries" + "github.com/glothriel/grf/pkg/serializers" + "github.com/glothriel/grf/pkg/views" + "github.com/google/uuid" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +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"` +} + +func SeedData(db *gorm.DB) ([]string, error) { + // Create two profiles + profiles := []Profile{ + { + BaseModel: models.BaseModel{ID: uuid.New()}, + Name: "Kajtek", + }, + { + BaseModel: models.BaseModel{ID: uuid.New()}, + Name: "Roksana", + }, + } + uuids := make([]string, 0, len(profiles)) + if err := db.Create(&profiles).Error; err != nil { + return uuids, fmt.Errorf("failed to create profiles: %w", err) + } + uuids = []string{profiles[0].ID.String(), profiles[1].ID.String()} + + photos := []Photo{ + { + ProfileID: profiles[0].ID, + }, + { + ProfileID: profiles[0].ID, + }, + { + ProfileID: profiles[0].ID, + }, + { + ProfileID: profiles[1].ID, + }, + { + ProfileID: profiles[1].ID, + }, + { + ProfileID: profiles[1].ID, + }, + } + + // Insert photos + if err := db.Create(&photos).Error; err != nil { + return uuids, fmt.Errorf("failed to create photos: %w", err) + } + + return uuids, nil +} + +func TestRelations(t *testing.T) { + gin.SetMode(gin.ReleaseMode) + router := gin.New() + gormDB, gormOpenErr := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, gormOpenErr) + + views.NewModelViewSet[Profile]( + "/profiles", + queries.GORM[Profile]( + gormDB, + ).WithPreload( + "photos", + ).WithOrderBy( + "`profiles`.`created_at` ASC", + ), + ).WithSerializer( + serializers.NewModelSerializer[Profile]().WithNewField( + serializers.NewSerializerField[Photo]( + "photos", + serializers.NewModelSerializer[Photo](), + ), + ), + ).Register(router) + autoMigrateErr := gormDB.AutoMigrate(Profile{}, Photo{}) + profileIDs, seedErr := SeedData(gormDB) + + require.NoError(t, autoMigrateErr) + require.NoError(t, seedErr) + + newRequestTestCase(t, "relations").Req( + newRequest("GET", "/profiles", nil), + ).ExCode( + http.StatusOK, + ).ExJson( + []any{ + map[string]any{ + "name": "Kajtek", + "photos": []any{ + map[string]any{ + "profile_id": profileIDs[0], + }, + map[string]any{ + "profile_id": profileIDs[0], + }, + map[string]any{ + "profile_id": profileIDs[0], + }, + }, + }, + map[string]any{ + "name": "Roksana", + "photos": []any{ + map[string]any{ + "profile_id": profileIDs[1], + }, + map[string]any{ + "profile_id": profileIDs[1], + }, + map[string]any{ + "profile_id": profileIDs[1], + }, + }, + }, + }, + ).Run(router) +} diff --git a/pkg/models/tags.go b/pkg/models/tags.go new file mode 100644 index 0000000..84b18ee --- /dev/null +++ b/pkg/models/tags.go @@ -0,0 +1,43 @@ +package models + +import ( + "reflect" + "strings" +) + +const tagID = "grf" + +// TagIsRelation is a tag that indicates that the field is a relation. +const TagIsRelation = "relation" + +// ParseTag parses the tag and returns a map of key-value pairs. +// Forma: `grf:"key1:value1;key2:value2"` +func ParseTag(f reflect.StructField) map[string]string { + tag := f.Tag.Get(tagID) + if tag == "" { + return map[string]string{} + } + + theMap := map[string]string{} + + pairs := strings.Split(tag, ";") + + for _, pair := range pairs { + if pair == "" { + continue + } + kv := strings.SplitN(pair, ":", 2) + key := strings.TrimSpace(kv[0]) + if key == "" { + continue + } + if len(kv) == 1 { + theMap[key] = "" + continue + } + value := strings.TrimSpace(kv[1]) + theMap[key] = value + } + + return theMap +} diff --git a/pkg/queries/common/common.go b/pkg/queries/common/common.go index 884493e..fe95251 100644 --- a/pkg/queries/common/common.go +++ b/pkg/queries/common/common.go @@ -10,3 +10,17 @@ type Pagination interface { QueryMod Format(*gin.Context, []any) (any, error) } + +type CompositeQueryMod struct { + children []QueryMod +} + +func (c CompositeQueryMod) Apply(ctx *gin.Context) { + for _, child := range c.children { + child.Apply(ctx) + } +} + +func NewCompositeQueryMod(children ...QueryMod) CompositeQueryMod { + return CompositeQueryMod{children: children} +} diff --git a/pkg/queries/gormq/gorm.go b/pkg/queries/gormq/gorm.go index 09cff8e..aca0ef4 100644 --- a/pkg/queries/gormq/gorm.go +++ b/pkg/queries/gormq/gorm.go @@ -2,11 +2,14 @@ package gormq import ( "fmt" + "reflect" "github.com/gin-gonic/gin" + "github.com/glothriel/grf/pkg/detectors" "github.com/glothriel/grf/pkg/models" "github.com/glothriel/grf/pkg/queries/common" "github.com/glothriel/grf/pkg/queries/crud" + "github.com/sirupsen/logrus" "gorm.io/gorm" ) @@ -33,20 +36,22 @@ func (g gormPagination[Model]) Format(ctx *gin.Context, elems []any) (any, error } type GormQueryDriver[Model any] struct { - crud *crud.CRUD[Model] - filter *gormQueryMod[Model] - order *gormQueryMod[Model] - pagination *gormPagination[Model] + filter *gormQueryMod[Model] + preloads *gormQueryMod[Model] + fieldNames map[string]string + preloadedQueries []string + order *gormQueryMod[Model] + pagination *gormPagination[Model] middleware []gin.HandlerFunc } func (g GormQueryDriver[Model]) CRUD() *crud.CRUD[Model] { - return g.crud + return GormQueries[Model](g.preloadedQueries) } func (g GormQueryDriver[Model]) Filter() common.QueryMod { - return g.filter + return common.NewCompositeQueryMod(g.filter, g.preloads) } func (g GormQueryDriver[Model]) Order() common.QueryMod { @@ -66,6 +71,20 @@ func (g *GormQueryDriver[Model]) WithFilter(filterFunc GormFilterFunc) *GormQuer return g } +func (g *GormQueryDriver[Model]) WithPreload(query string, args ...any) *GormQueryDriver[Model] { + g.preloads.modFunc = func(ctx *gin.Context, db *gorm.DB) *gorm.DB { + fieldName, ok := g.fieldNames[query] + if !ok { + logrus.Errorf("Could not find field name for query %s, skipping preload", query) + } else { + db = db.Preload(fieldName, args...) + } + return db + } + g.preloadedQueries = append(g.preloadedQueries, query) + return g +} + func (g *GormQueryDriver[Model]) WithPagination(pagination Pagination) *GormQueryDriver[Model] { g.pagination.child = pagination return g @@ -80,12 +99,18 @@ func (g *GormQueryDriver[Model]) WithOrderBy(orderClause any) *GormQueryDriver[M func Gorm[Model any](factory GormORMFactory) *GormQueryDriver[Model] { return &GormQueryDriver[Model]{ - crud: GormQueries[Model](), + preloadedQueries: []string{}, + fieldNames: detectors.FieldNames[Model](), filter: &gormQueryMod[Model]{ modFunc: func(ctx *gin.Context, db *gorm.DB) *gorm.DB { return db }, }, + preloads: &gormQueryMod[Model]{ + modFunc: func(ctx *gin.Context, db *gorm.DB) *gorm.DB { + return db + }, + }, order: &gormQueryMod[Model]{ modFunc: func(ctx *gin.Context, db *gorm.DB) *gorm.DB { return db @@ -105,25 +130,45 @@ func Gorm[Model any](factory GormORMFactory) *GormQueryDriver[Model] { } // GormQueries returns default queries providing basic CRUD functionality -func GormQueries[Model any]() *crud.CRUD[Model] { +func GormQueries[Model any](preloadedQueries []string) *crud.CRUD[Model] { ConvertFromDBToInternalValue := FromDBConverter[Model]() var empty Model + var preloadedQueriesMap = make(map[string]bool) + for _, query := range preloadedQueries { + preloadedQueriesMap[query] = true + } return &crud.CRUD[Model]{ List: func(ctx *gin.Context) ([]models.InternalValue, error) { - rawEntities := []map[string]any{} - findErr := CtxQuery(ctx).Model(&empty).Find(&rawEntities).Error + rawEntities := []models.InternalValue{} + typedEntities := []Model{} + findErr := CtxQuery(ctx).Model(&empty).Find(&typedEntities).Error if findErr != nil { return nil, findErr } - internalValues := make([]models.InternalValue, len(rawEntities)) - for i, rawEntity := range rawEntities { - internalValue, convertErr := ConvertFromDBToInternalValue(rawEntity) - if convertErr != nil { - return nil, convertErr + for _, entity := range typedEntities { + iv := models.AsInternalValue(entity) + for k, v := range iv { + if _, ok := preloadedQueriesMap[k]; ok { + vValue := reflect.ValueOf(v) + + if vValue.Kind() == reflect.Slice { + newSlice := make([]any, vValue.Len()) + + for i := 0; i < vValue.Len(); i++ { + newSlice[i] = models.AsInternalValue(vValue.Index(i).Interface()) + } + + iv[k] = newSlice + } else { + iv[k] = models.AsInternalValue(v) + } + } else { + iv[k] = v + } } - internalValues[i] = internalValue + rawEntities = append(rawEntities, iv) } - return internalValues, findErr + return rawEntities, findErr }, Retrieve: func(ctx *gin.Context, id any) (models.InternalValue, error) { var rawEntity map[string]any diff --git a/pkg/serializers/field.go b/pkg/serializers/field.go new file mode 100644 index 0000000..0b4a035 --- /dev/null +++ b/pkg/serializers/field.go @@ -0,0 +1,41 @@ +package serializers + +import ( + "github.com/gin-gonic/gin" + "github.com/glothriel/grf/pkg/fields" + "github.com/glothriel/grf/pkg/models" +) + +type SerializerField[Model any] struct { + fields.Field + serializer Serializer +} + +func (s *SerializerField[Model]) ToRepresentation(iv models.InternalValue, c *gin.Context) (any, error) { + fieldValue := iv[s.Name()] + asSlice, isSlice := fieldValue.([]any) + if isSlice { + result := make([]any, 0) + for _, item := range asSlice { + itemIV, isInternalValue := item.(models.InternalValue) + if !isInternalValue { + return nil, fields.NewErrorFieldIsNotPresentInPayload(s.Name()) + } + serialized, err := s.serializer.ToRepresentation(itemIV, c) + if err != nil { + return nil, err + } + result = append(result, serialized) + } + return result, nil + } + return s.serializer.ToRepresentation(iv, c) +} + +func (s *SerializerField[Model]) ToInternalValue(raw map[string]any, c *gin.Context) (any, error) { + return s.serializer.ToInternalValue(raw, c) +} + +func NewSerializerField[Model any](name string, serializer Serializer) fields.Field { + return &SerializerField[Model]{fields.NewField[Model](name), serializer} +} diff --git a/pkg/serializers/model.go b/pkg/serializers/model.go index 8185932..4848bbe 100644 --- a/pkg/serializers/model.go +++ b/pkg/serializers/model.go @@ -12,7 +12,7 @@ import ( ) type ModelSerializer[Model any] struct { - Fields map[string]*fields.Field[Model] + Fields map[string]fields.Field toRepresentationDetector detectors.ToRepresentationDetector[Model] toInternalValueDetector detectors.ToInternalValueDetector @@ -28,7 +28,7 @@ func (s *ModelSerializer[Model]) ToInternalValue(raw map[string]any, ctx *gin.Co superfluousFields = append(superfluousFields, k) continue } - if !field.Writable { + if !field.IsWritable() { continue } // Please remember, that `ToInteralValue` doesn't necessarily extract the value from the `raw` map. @@ -56,7 +56,7 @@ func (s *ModelSerializer[Model]) ToInternalValue(raw map[string]any, ctx *gin.Co func (s *ModelSerializer[Model]) ToRepresentation(intVal models.InternalValue, ctx *gin.Context) (Representation, error) { raw := make(map[string]any) for _, field := range s.Fields { - if !field.Readable { + if !field.IsReadable() { continue } value, err := field.ToRepresentation(intVal, ctx) @@ -83,12 +83,12 @@ func (s *ModelSerializer[Model]) Validate(intVal models.InternalValue, ctx *gin. return nil } -func (s *ModelSerializer[Model]) WithNewField(field *fields.Field[Model]) *ModelSerializer[Model] { +func (s *ModelSerializer[Model]) WithNewField(field fields.Field) *ModelSerializer[Model] { s.Fields[field.Name()] = field return s } -func (s *ModelSerializer[Model]) WithField(name string, updateFunc func(oldField *fields.Field[Model])) *ModelSerializer[Model] { +func (s *ModelSerializer[Model]) WithField(name string, updateFunc func(oldField fields.Field)) *ModelSerializer[Model] { v, ok := s.Fields[name] if !ok { var m Model @@ -100,7 +100,7 @@ func (s *ModelSerializer[Model]) WithField(name string, updateFunc func(oldField func (s *ModelSerializer[Model]) WithModelFields(passedFields []string) *ModelSerializer[Model] { - s.Fields = make(map[string]*fields.Field[Model]) + s.Fields = make(map[string]fields.Field) var m Model for _, field := range passedFields { toRepresentation, toRepresentationErr := s.toRepresentationDetector.ToRepresentation(field) @@ -132,10 +132,15 @@ func (s *ModelSerializer[Model]) WithModelFields(passedFields []string) *ModelSe } func NewModelSerializer[Model any]() *ModelSerializer[Model] { + // Includes all the fields + return NewModelSerializerWithFields[Model](detectors.Fields[Model]()) +} + +func NewModelSerializerWithFields[Model any](fieldList []string) *ModelSerializer[Model] { return (&ModelSerializer[Model]{ toRepresentationDetector: detectors.DefaultToRepresentationDetector[Model](), toInternalValueDetector: detectors.DefaultToInternalValueDetector[Model](), }).WithModelFields( - detectors.Fields[Model](), - ).WithField("id", func(oldField *fields.Field[Model]) { oldField.ReadOnly() }) + fieldList, + ).WithField("id", func(oldField fields.Field) { oldField.WithReadOnly() }) } diff --git a/pkg/serializers/model_test.go b/pkg/serializers/model_test.go index 5e95293..dfb00a0 100644 --- a/pkg/serializers/model_test.go +++ b/pkg/serializers/model_test.go @@ -38,8 +38,8 @@ func TestModelSerializerToInternalValueNonWritableField(t *testing.T) { // given serializer := NewModelSerializer[anotherMockModel]().WithField( "bar", - func(oldField *fields.Field[anotherMockModel]) { - oldField.ReadOnly() + func(oldField fields.Field) { + oldField.WithReadOnly() }, ) @@ -84,8 +84,8 @@ func TestModelSerializerToRepresentationNonReadableField(t *testing.T) { // given serializer := NewModelSerializer[anotherMockModel]().WithField( "bar", - func(oldField *fields.Field[anotherMockModel]) { - oldField.WriteOnly() + func(oldField fields.Field) { + oldField.WithWriteOnly() }, ) @@ -153,7 +153,7 @@ func TestModelSerializerWithField(t *testing.T) { // given serializer := NewModelSerializer[mockModel]().WithField( "foo", - func(f *fields.Field[mockModel]) { + func(f fields.Field) { f.WithInternalValueFunc( func(m map[string]any, s string, ctx *gin.Context) (any, error) { return m[s].(string) + " huehue", nil @@ -178,7 +178,7 @@ func TestModelSerializerWithFieldDoesNotExist(t *testing.T) { assert.Panics(t, func() { serializer.WithField( "bar", - func(f *fields.Field[mockModel]) { + func(f fields.Field) { f.WithInternalValueFunc( func(m map[string]any, s string, ctx *gin.Context) (any, error) { return nil, nil diff --git a/pkg/views/view.go b/pkg/views/view.go index 1d31a7c..7da9012 100644 --- a/pkg/views/view.go +++ b/pkg/views/view.go @@ -62,7 +62,7 @@ func (v *View) AddMiddleware(m ...gin.HandlerFunc) *View { return v } -func (v *View) Register(r *gin.Engine) { +func (v *View) Register(r gin.IRouter) { rg := r.Group(v.path, v.middleware...) if v.getHandler != nil { rg.GET("", v.getHandler) diff --git a/pkg/views/viewsets.go b/pkg/views/viewsets.go index 1498622..d121554 100644 --- a/pkg/views/viewsets.go +++ b/pkg/views/viewsets.go @@ -11,7 +11,6 @@ import ( "github.com/glothriel/grf/pkg/queries/crud" "github.com/glothriel/grf/pkg/serializers" "github.com/glothriel/grf/pkg/types" - "github.com/sirupsen/logrus" ) const ( @@ -48,7 +47,6 @@ func (v *ViewSet[Model]) WithExtraAction( ) *ViewSet[Model] { view := v.ListCreateView if isDetail { - logrus.Error("huehueh") view = v.RetrieveUpdateDestroyView } @@ -60,7 +58,7 @@ func (v *ViewSet[Model]) WithExtraAction( return v } -func (v *ViewSet[Model]) Register(r *gin.Engine) { +func (v *ViewSet[Model]) Register(r gin.IRouter) { if v.ListAction != nil { v.ListCreateView.Get(v.ListAction.ViewSetHandlerFactoryFunc(v.IDFunc, v.QueryDriver, v.ListAction.Serializer)) } @@ -200,20 +198,32 @@ func (v *ViewSet[Model]) WithDestroy(handlerFactoryFunc ViewSetHandlerFactoryFun } func (v *ViewSet[Model]) WithActions(actions ...ActionID) *ViewSet[Model] { + actionsMap := []struct { + id ActionID + setFunc func(ViewSetHandlerFactoryFunc[Model]) *ViewSet[Model] + clearPtr **ViewSetAction[Model] + factory ViewSetHandlerFactoryFunc[Model] + }{ + {ActionCreate, v.WithCreate, &v.CreateAction, CreateModelViewSetFunc[Model]}, + {ActionUpdate, v.WithUpdate, &v.UpdateAction, UpdateModelViewSetFunc[Model]}, + {ActionDestroy, v.WithDestroy, &v.DestroyAction, DestroyModelViewSetFunc[Model]}, + {ActionList, v.WithList, &v.ListAction, ListModelViewSetFunc[Model]}, + {ActionRetrieve, v.WithRetrieve, &v.RetrieveAction, RetrieveModelViewSetFunc[Model]}, + } + + actionSet := make(map[ActionID]bool) for _, action := range actions { - switch action { - case ActionCreate: - v.WithCreate(CreateModelViewSetFunc[Model]) - case ActionUpdate: - v.WithUpdate(UpdateModelViewSetFunc[Model]) - case ActionDestroy: - v.WithDestroy(DestroyModelViewSetFunc[Model]) - case ActionList: - v.WithList(ListModelViewSetFunc[Model]) - case ActionRetrieve: - v.WithRetrieve(RetrieveModelViewSetFunc[Model]) + actionSet[action] = true + } + + for _, action := range actionsMap { + if actionSet[action.id] { + action.setFunc(action.factory) + } else { + *action.clearPtr = nil } } + return v } @@ -233,10 +243,14 @@ func (v *ViewSet[Model]) OnDestroy(modFunc func(d crud.DestroyQueryFunc) crud.De } func NewModelViewSet[Model any](path string, queryDriver queries.Driver[Model]) *ViewSet[Model] { - return NewViewSet(path, queryDriver).WithActions(ActionCreate, ActionUpdate, ActionDestroy, ActionList, ActionRetrieve) + return NewViewSet(path, queryDriver, serializers.NewModelSerializer[Model]()).WithActions(ActionCreate, ActionUpdate, ActionDestroy, ActionList, ActionRetrieve) } -func NewViewSet[Model any](routerPath string, queryDriver queries.Driver[Model]) *ViewSet[Model] { +func NewViewSet[Model any]( + routerPath string, + queryDriver queries.Driver[Model], + defaultSerializer serializers.Serializer, +) *ViewSet[Model] { // I really don't like that, the ID param is not just "id", but otherwise Gin throws a panic when // trying to use ViewSets on paths with multiple IDs. // For example /products/:product_id/photos/:id will panic if you already have /products/:id registered (product_id != id). @@ -253,7 +267,7 @@ func NewViewSet[Model any](routerPath string, queryDriver queries.Driver[Model]) Path: routerPath, QueryDriver: queryDriver, IDFunc: IDFromPathParam(idParamName), - DefaultSerializer: serializers.NewModelSerializer[Model](), + DefaultSerializer: defaultSerializer, ListCreateView: NewView(routerPath, queryDriver), RetrieveUpdateDestroyView: NewView(retrieveUpdateDestroyPath, queryDriver), } diff --git a/pkg/views/viewsets_test.go b/pkg/views/viewsets_test.go index 6c8f7b6..2139cc7 100644 --- a/pkg/views/viewsets_test.go +++ b/pkg/views/viewsets_test.go @@ -96,7 +96,7 @@ var caseDestroy viewsetTestCase = viewsetTestCase{ } func TestEmptyViewsetRespondsWithMethodNotFound(t *testing.T) { - viewset := NewViewSet[anotherMockModel]("/mocks", queries.InMemory[anotherMockModel]()) + viewset := NewViewSet[anotherMockModel]("/mocks", queries.InMemory[anotherMockModel](), serializers.NewModelSerializer[anotherMockModel]()) _, r := gin.CreateTestContext(httptest.NewRecorder()) viewset.Register(r) @@ -114,7 +114,7 @@ func TestEmptyViewsetRespondsWithMethodNotFound(t *testing.T) { } } func TestViewsetWhenOnlyListActionRegisteredAllOthersReturn404(t *testing.T) { - viewset := NewViewSet[anotherMockModel]("/mocks", queries.InMemory[anotherMockModel]()).WithActions(ActionList) + viewset := NewViewSet[anotherMockModel]("/mocks", queries.InMemory[anotherMockModel](), serializers.NewModelSerializer[anotherMockModel]()).WithActions(ActionList) _, r := gin.CreateTestContext(httptest.NewRecorder()) viewset.Register(r) @@ -164,7 +164,7 @@ func TestNewModelViewset(t *testing.T) { } func TestNewViewSetAllActions(t *testing.T) { checkAllActionsNoErrors( - t, NewViewSet[anotherMockModel]("/mocks", queries.InMemory[anotherMockModel]()).WithActions( + t, NewViewSet[anotherMockModel]("/mocks", queries.InMemory[anotherMockModel](), serializers.NewModelSerializer[anotherMockModel]()).WithActions( ActionList, ActionCreate, ActionRetrieve, ActionUpdate, ActionDestroy, ), )