From cb9f979204434bbc4f81647498927f986d39d7fc Mon Sep 17 00:00:00 2001 From: Mikolaj Gasior Date: Fri, 27 Dec 2024 20:32:28 +0100 Subject: [PATCH 1/5] WIP --- .github/workflows/tests.yml | 23 ++ LICENSE | 2 +- README.md | 195 +++++++++++++++- doc.go | 8 + err.go | 30 +++ funcs.go | 10 + go.mod | 45 ++++ go.sum | 129 +++++++++++ http.go | 107 +++++++++ http_joined_test.go | 105 +++++++++ http_test.go | 241 +++++++++++++++++++ httpresponse.go | 16 ++ internal.go | 73 ++++++ internal_http.go | 370 +++++++++++++++++++++++++++++ main.go | 43 ++++ main_test.go | 448 ++++++++++++++++++++++++++++++++++++ orm.go | 142 ++++++++++++ validation.go | 91 ++++++++ 18 files changed, 2076 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/tests.yml create mode 100644 doc.go create mode 100644 err.go create mode 100644 funcs.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 http.go create mode 100644 http_joined_test.go create mode 100644 http_test.go create mode 100644 httpresponse.go create mode 100644 internal.go create mode 100644 internal_http.go create mode 100644 main.go create mode 100644 main_test.go create mode 100644 orm.go create mode 100644 validation.go diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..a322702 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,23 @@ +name: Test build + +on: + pull_request: + branches: + - main + +jobs: + main: + name: Build and run + runs-on: ubuntu-20.04 + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Run tests + run: | + go test + + - name: Check if binary builds + run: | + go build . + diff --git a/LICENSE b/LICENSE index ebd4008..0946776 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ BSD 2-Clause License -Copyright (c) 2024, Go Phings +Copyright (c) 2023-2024, Mikolaj Gasior Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: diff --git a/README.md b/README.md index 900dc90..ab7e957 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,195 @@ # crud -Create CRUD endpoints and store in the database with ease + +[![Go Reference](https://pkg.go.dev/badge/github.com/go-phings/crud.svg)](https://pkg.go.dev/github.com/go-phings/crud) [![Go Report Card](https://goreportcard.com/badge/github.com/go-phings/crud)](https://goreportcard.com/report/github.com/go-phings/crud) + +Package `crud` is meant to create REST API HTTP endpoint for simple data management. + +HTTP endpoint can be set to allow creating, updating, removing new object, along with returning its details, +or list of objects. All requests and responses are in the JSON format. + +## Example usage + +### TL;DR +Check code in the `*_test.go` files, starting with `main_test.go` to get examples. + +### Defining structs (models) +Models are defined with structs as follows (take a closer look at the tags): + +``` +type User struct { + ID int `json:"user_id"` + Flags int `json:"flags"` + Name string `json:"name" crud:"req lenmin:2 lenmax:50"` + Email string `json:"email" crud:"req"` + Password string `json:"password"` + EmailActivationKey string `json:"email_activation_key" crud:""` + CreatedAt int `json:"created_at"` + CreatedByUserID int `json:"created_by_user_id"` +} + +type Session struct { + ID int `json:"session_id"` + Flags int `json:"flags"` + Key string `json:"key" crud:"uniq lenmin:32 lenmax:50"` + ExpiresAt int `json:"expires_at"` + UserID int `json:"user_id" crud:"req"` +} + +type Something struct { + ID int `json:"something_id"` + Flags int `json:"flags"` + Email string `json:"email" crud:"req"` + Age int `json:"age" crud:"req valmin:18 valmax:130 val:18"` + Price int `json:"price" crud:"req valmin:0 valmax:9900 val:100"` + CurrencyRate int `json:"currency_rate" crud:"req valmin:40000 valmax:61234 val:10000"` + PostCode string `json:"post_code" crud:"req val:32-600"` +} +``` + + +#### Field tags +Struct tags define ORM behaviour. `crud` parses tags such as `crud`, `http` and various tags starting with +`crud_`. Apart from the last one, a tag define many properties which are separated with space char, and if they +contain a value other than bool (true, false), it is added after semicolon char. +See below list of all the tags with examples. + +Tag | Example | Explanation +--- | --- | --- +`crud` | `crud:"req valmin:0 valmax:130 val:18"` | Struct field properties defining its valid value for model. See RESTAPI Field Properties for more info +`crud_val` | `crud_val:"Default value"` | Struct field default value +`crud_regexp` | `crud_regexp:"^[0-9]{2}\\-[0-9]{3}$"` | Regular expression that struct field must match +`crud_testvalpattern` | `crud_testvalpattern:DD-DDD` | Very simple pattern for generating valid test value (used for tests). In the string, `D` is replaced with a digit + + +##### CRUD Field Properties +Property | Explanation +--- | --- +`req` | Field is required +`uniq` | Field has to be unique (like `UNIQUE` on the database column) +`valmin` | If field is numeric, this is minimal value for the field +`valmax` | If field is numeric, this is maximal value for the field +`val` | Default value for the field. If the value is not a simple, short alphanumeric, use the `crud_val` tag for it +`lenmin` | If field is string, this is a minimal length of the field value +`lenmax` | If field is string, this is a maximal length of the field value +`password` | Field is a password, and a function that generates it can be attached (see `ControllerConfig`) +`hidden` | Field's value will not be shown when listing item(s) + + +### Database storage +Currently, `crud` supports only PostgreSQL as a storage for objects. + +#### Controller +To perform model database actions, a `Controller` object must be created. See below example that modify object(s) +in the database. + +``` +import ( + crud "github.com/mikolajgs/prototyping/pkg/rest-api" +) +``` + +``` +// Create connection with sql +conn, _ := sql.Open("postgres", fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable", dbHost, dbPort, dbUser, dbPass, dbName)) +defer conn.Close() + +// Create RESTAPI controller and an instance of a struct +c := crud.NewController(conn, "app1_", nil) +user := &User{} + +err = c.CreateTable(user) // Run 'CREATE TABLE' +``` + +#### ControllerConfig + +`crud.&ControllerConfig{}` can be passed to a constructor. It contains the following fields: + +* `TagName` can be used to change the name of the tag in struct (by the default it is `crud`) +* `PasswordGenerator` - when a field is a `password` (see field tags above), a function can be attached, that generates the value to be put in the database + +### HTTP Endpoints +With `crud`, HTTP endpoints can be created to manage objects stored in the database. + +If User struct is used for HTTP endpoint, fields such as `Password` will be present when listing users. Therefore, +it's necessary to create new structs to define CRUD endpoints' input and/or output. These structs unfortunately need +validation tags (which can be different than the ones from "main" struct). + +In below example, `User_Create` defines input fields when creating a User, `User_Update` defines fields that are +meant to change when permorming update, `User_UpdatePassword` is an additional struct just for updating User +password, and finally - fields in `User_List` will be visible when listing users or reading one user. (You can +define these as you like). +``` +type User_Create { + ID int `json:"user_id"` + Name string `json:"name" crud:"req lenmin:2 lenmax:50"` + Email string `json:"email" crud:"req"` + Password string `json:"password"` +} +type User_Update { + ID int `json:"user_id"` + Name string `json:"name" crud:"req lenmin:2 lenmax:50"` + Email string `json:"email" crud:"req"` +} +type User_UpdatePassword { + ID int `json:"user_id"` + Password string `json:"password"` +} +type User_List { + ID int `json:"user_id"` + Name string `json:"name" +} +``` + +``` +var parentFunc = func() interface{} { return &User; } +var createFunc = func() interface{} { return &User_Create; } +var readFunc = func() interface{} { return &User_List; } +var updateFunc = func() interface{} { return &User_Update; } +var listFunc = func() interface{} { return &User_List; } + +var updatePasswordFunc = func() interface{} { return &User_UpdatePassword; } + +http.HandleFunc("/users/", c.Handler("/users/", parentFunc, HandlerOptions{ + CreateConstructor: createFunc, // input fields (and JSON payload) for creating + ReadConstructor: readFunc, // output fields (and JSON output) for reading + UpdateConstructor: updateFunc, // input fields (and JSON payload) for updating + ListConstructor: listFunc, // fields to appear when listing items (and JSON output) +})) +http.HandleFunc("/users/password/", c.Handler("/users/password/", parentFunc, HandlerOptions{ + UpdateConstructor: updatePasswordFunc, // input fields for that one updating endpoint + Operations: OpUpdate, // only updating will be allowed +})) +log.Fatal(http.ListenAndServe(":9001", nil)) +``` + +In the example, `/users/` CRUDL endpoint is created and it allows to: +* create new User by sending JSON payload using PUT method +* update existing User by sending JSON payload to `/users/:id` with PUT method +* get existing User details with making GET request to `/users/:id` +* delete existing User with DELETE request to `/users/:id` +* get list of Users with making GET request to `/users/` with optional query parameters such as `limit`, `offset` to slice the returned list and `filter_` params (eg. `filter_email`) to filter out records with by specific fields + +When creating or updating an object, JSON payload with object details is +required. It should match the struct used for Create and Update operations. +In this case, `User_Create` and `User_Update`. + +``` +{ + "email": "test@example.com", + "name": "Jane Doe", + ... +} +``` + +Output from the endpoint is in JSON format as well and it follows below +structure: + +``` +{ + "ok": 1, + "err_text": "...", + "data": { + ... + } +} +``` diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..07ea5df --- /dev/null +++ b/doc.go @@ -0,0 +1,8 @@ +// Package crud is meant to create REST API HTTP endpoint for simple data management. +// +// HTTP endpoint can be set to allow creating, updating, removing new object, along with returning its details, +// or list of objects. All requests and responses are in the JSON format. +// +// Please follow GitHub page for an example: +// https://github.com/go-phings/crud/ +package crud diff --git a/err.go b/err.go new file mode 100644 index 0000000..bbd9216 --- /dev/null +++ b/err.go @@ -0,0 +1,30 @@ +package crud + +// ErrController wraps original error that occurred in Err with name of the operation/step that failed, which is +// in Op field +type ErrController struct { + Op string + Err error +} + +func (e *ErrController) Error() string { + return e.Err.Error() +} + +func (e *ErrController) Unwrap() error { + return e.Err +} + +// ErrValidation wraps error occurring during object validation +type ErrValidation struct { + Fields map[string]int + Err error +} + +func (e ErrValidation) Error() string { + return e.Err.Error() +} + +func (e ErrValidation) Unwrap() error { + return e.Err +} diff --git a/funcs.go b/funcs.go new file mode 100644 index 0000000..4733659 --- /dev/null +++ b/funcs.go @@ -0,0 +1,10 @@ +package crud + +import "reflect" + +func getStructName(u interface{}) string { + v := reflect.ValueOf(u) + i := reflect.Indirect(v) + s := i.Type() + return s.Name() +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..10f9238 --- /dev/null +++ b/go.mod @@ -0,0 +1,45 @@ +module github.com/go-phings/crud + +go 1.23.4 + +require ( + github.com/go-phings/struct-db-postgres v0.6.0 + github.com/go-phings/struct-sql-postgres v0.6.0 + github.com/go-phings/struct-validator v0.4.7 + github.com/lib/pq v1.10.9 + github.com/mikolajgs/prototyping/pkg/umbrella v0.0.0-20241129204714-bf573dcaf2c8 + github.com/ory/dockertest/v3 v3.11.0 +) + +require ( + dario.cat/mergo v1.0.0 // indirect + github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/containerd/continuity v0.4.3 // indirect + github.com/dgrijalva/jwt-go v3.2.0+incompatible // indirect + github.com/docker/cli v26.1.4+incompatible // indirect + github.com/docker/docker v27.1.1+incompatible // indirect + github.com/docker/go-connections v0.5.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mikolajgs/prototyping v0.2.1 // indirect + github.com/mikolajgs/struct-validator v0.4.7 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/term v0.5.0 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.0 // indirect + github.com/opencontainers/runc v1.1.13 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect + github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect + github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect + github.com/xeipuuv/gojsonschema v1.2.0 // indirect + golang.org/x/crypto v0.29.0 // indirect + golang.org/x/sys v0.27.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..3619fb0 --- /dev/null +++ b/go.sum @@ -0,0 +1,129 @@ +dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= +dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw= +github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/containerd/continuity v0.4.3 h1:6HVkalIp+2u1ZLH1J/pYX2oBVXlJZvh1X1A7bEZ9Su8= +github.com/containerd/continuity v0.4.3/go.mod h1:F6PTNCKepoxEaXLQp3wDAjygEnImnZ/7o4JzpodfroQ= +github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= +github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +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/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= +github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= +github.com/docker/cli v26.1.4+incompatible h1:I8PHdc0MtxEADqYJZvhBrW9bo8gawKwwenxRM7/rLu8= +github.com/docker/cli v26.1.4+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/docker v27.1.1+incompatible h1:hO/M4MtV36kzKldqnA37IWhebRA+LnqqcqDja6kVaKY= +github.com/docker/docker v27.1.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/go-phings/struct-db-postgres v0.6.0 h1:EbxRIrkwF2cDdTYr5TUJ2Ot3N+xSvKUxY4brJGIuloA= +github.com/go-phings/struct-db-postgres v0.6.0/go.mod h1:Yw7QdVcY6NZGp/gbn4sVGZLiCJF7YZR9mFx2HuWS+i0= +github.com/go-phings/struct-sql-postgres v0.6.0 h1:GvaiSnywCnA4RtRfQEkYOtNwnykKHW158if0Yoi8jCs= +github.com/go-phings/struct-sql-postgres v0.6.0/go.mod h1:bII8gzJnuAn7Ht+NEmHnyieu53EYnHhGg5EeY893yeE= +github.com/go-phings/struct-validator v0.4.7 h1:qHCkn2ppOnzRxzE3+UCXD2+4bMp4xWtEcl5S/TKebRo= +github.com/go-phings/struct-validator v0.4.7/go.mod h1:i+WCf5KGFnxiGdrtqsysAaIACvE5K+h5X/R8gPJQPB0= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= +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/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mikolajgs/prototyping v0.2.1 h1:bkC/3Td5A6QtEHfv66i36Jamjj7zJI/T5zUoyC+KvTs= +github.com/mikolajgs/prototyping v0.2.1/go.mod h1:1O3vsOStgBfkTV6/y7bywkoySfPHt3lfFT74fQe6iqk= +github.com/mikolajgs/prototyping/pkg/umbrella v0.0.0-20241129204714-bf573dcaf2c8 h1:rJKU3AT0bChaCe8B53J+zPOT6TzicU4QlDW/uhial00= +github.com/mikolajgs/prototyping/pkg/umbrella v0.0.0-20241129204714-bf573dcaf2c8/go.mod h1:rOkaJr2iIHXW3WexmwqqXRVEcC9dmHnV34II60WKOcM= +github.com/mikolajgs/struct-validator v0.4.7 h1:6kBLsnBqC5KQpwY07n3yiqFUK3hm+f4KuHcYSceN4kY= +github.com/mikolajgs/struct-validator v0.4.7/go.mod h1:Ks0Lm870PpN0ZuQ+LDKYhCazSNyN3zvX5NrFxwlS69g= +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/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/opencontainers/runc v1.1.13 h1:98S2srgG9vw0zWcDpFMn5TRrh8kLxa/5OFUstuUhmRs= +github.com/opencontainers/runc v1.1.13/go.mod h1:R016aXacfp/gwQBYw2FDGa9m+n6atbLWrYY8hNMT/sA= +github.com/ory/dockertest/v3 v3.11.0 h1:OiHcxKAvSDUwsEVh2BjxQQc/5EHz9n0va9awCtNGuyA= +github.com/ory/dockertest/v3 v3.11.0/go.mod h1:VIPxS1gwT9NpPOrfD3rACs8Y9Z7yhzO4SB194iUDnUI= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +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.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= +github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= +github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= +github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ= +golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= +golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +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= +gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= +gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= diff --git a/http.go b/http.go new file mode 100644 index 0000000..0bb64c3 --- /dev/null +++ b/http.go @@ -0,0 +1,107 @@ +package crud + +import ( + "net/http" +) + +type HandlerOptions struct { + CreateConstructor func() interface{} + ReadConstructor func() interface{} + UpdateConstructor func() interface{} + ListConstructor func() interface{} + Operations int + ForceName string +} + +// Values for CRUD operations +// Same as in the "umbrella" package: http://github.com/mikolajgs/prototyping/pkg/umbrella +// refactor: work on these guys to be different? +const OpAll = 0 +const OpRead = 16 +const OpUpdate = 32 +const OpCreate = 8 +const OpDelete = 64 +const OpList = 128 + +// Handler returns a REST API HTTP handler that can be attached to HTTP server. It creates a CRUD endpoint +// for creating, reading, updating, deleting and listing objects. +// Each of the func() argument should be funcs that create new object (instance of a struct). For each of the +// operation (create, read etc.), a different struct with different fields can be used. It's important to pass +// "uri" argument same as the one that the handler is attached to. +func (c Controller) Handler(uri string, constructor func() interface{}, options HandlerOptions) http.Handler { + c.initHelpers(constructor, options) + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + id, b := c.getIDFromURI(r.RequestURI[len(uri):], w) + if !b { + return + } + + structName := getStructName(constructor()) + + if r.Method == http.MethodPut && id == "" && (options.Operations == OpAll || options.Operations&OpCreate > 0) { + if !c.isStructOperationAllowed(r, structName, OpCreate) { + c.writeErrText(w, http.StatusForbidden, "access_denied") + return + } + + if options.CreateConstructor != nil { + c.handleHTTPPut(w, r, options.CreateConstructor, id) + } else { + c.handleHTTPPut(w, r, constructor, id) + } + return + } + if r.Method == http.MethodPut && id != "" && (options.Operations == OpAll || options.Operations&OpUpdate > 0) { + if !c.isStructOperationAllowed(r, structName, OpUpdate) { + c.writeErrText(w, http.StatusForbidden, "access_denied") + return + } + + if options.UpdateConstructor != nil { + c.handleHTTPPut(w, r, options.UpdateConstructor, id) + } else { + c.handleHTTPPut(w, r, constructor, id) + } + return + } + + if r.Method == http.MethodGet && id != "" && (options.Operations == OpAll || options.Operations&OpRead > 0) { + if !c.isStructOperationAllowed(r, structName, OpRead) { + c.writeErrText(w, http.StatusForbidden, "access_denied") + return + } + + if options.ReadConstructor != nil { + c.handleHTTPGet(w, r, options.ReadConstructor, id) + } else { + c.handleHTTPGet(w, r, constructor, id) + } + return + } + if r.Method == http.MethodGet && id == "" && (options.Operations == OpAll || options.Operations&OpList > 0) { + if !c.isStructOperationAllowed(r, structName, OpRead) { + c.writeErrText(w, http.StatusForbidden, "access_denied") + return + } + + if options.ListConstructor != nil { + c.handleHTTPGet(w, r, options.ListConstructor, id) + } else { + c.handleHTTPGet(w, r, constructor, id) + } + return + } + if r.Method == http.MethodDelete && id != "" && (options.Operations == OpAll || options.Operations&OpDelete > 0) { + if !c.isStructOperationAllowed(r, structName, OpDelete) { + c.writeErrText(w, http.StatusForbidden, "access_denied") + return + } + + c.handleHTTPDelete(w, r, constructor, id) + return + } + + w.WriteHeader(http.StatusBadRequest) + }) +} diff --git a/http_joined_test.go b/http_joined_test.go new file mode 100644 index 0000000..0e718c9 --- /dev/null +++ b/http_joined_test.go @@ -0,0 +1,105 @@ +package crud + +import ( + "bytes" + "io" + "net/http" + "testing" +) + +type ProductKind struct { + ID int64 + Name string +} + +type ProductGroup struct { + ID int64 + Name string + Description string + Code string +} + +type Product struct { + ID int64 + Name string + Price int + ProductKindID int64 + ProductGrpID int64 +} + +type Product_WithDetails struct { + ID int64 `json:"product_id"` + Name string `json:"name"` + Price int `json:"price"` + ProductKindID int64 `json:"product_kind_id"` + ProductGrpID int64 `json:"product_grp_id"` + ProductKind *ProductKind `crud:"join" json:"omit"` + ProductKind_Name string `json:"product_kind_name"` + ProductGrp *ProductGroup `crud:"join" json:"omit"` + ProductGrp_Code string `json:"product_grp_code"` +} + +func TestGetListOfJoinedStructs(t *testing.T) { + // Create tables + ctl.orm.CreateTables(&Product{}, &ProductGroup{}, &ProductKind{}) + + // Add rows + pg := &ProductGroup{ + ID: 113, + Name: "Group 1", + Description: "A group of products", + Code: "GRP1", + } + ctl.orm.Save(pg) + + pk := &ProductKind{ + ID: 33, + Name: "Kind 1", + } + ctl.orm.Save(pk) + pk2 := &ProductKind{ + ID: 34, + Name: "Kind 2", + } + ctl.orm.Save(pk2) + + p := &Product{ + ID: 6, + Name: "Product Name", + Price: 1234, + ProductKindID: 33, + ProductGrpID: 113, + } + ctl.orm.Save(p) + p2 := &Product{ + ID: 7, + Name: "Product Name 2", + Price: 1234, + ProductKindID: 34, + ProductGrpID: 113, + } + ctl.orm.Save(p2) + + uriParamString := "" + req, err := http.NewRequest("GET", "http://localhost:"+httpPort+httpURIJoined+"?"+uriParamString, bytes.NewReader([]byte{})) + if err != nil { + t.Fatalf("GET method failed on HTTP server with handler from GetHTTPHandler: %s", err) + } + c := &http.Client{} + resp, err := c.Do(req) + if err != nil { + t.Fatalf("GET method failed on HTTP server with handler from GetHTTPHandler: %s", err) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("GET method returned wrong status code, want %d, got %d", http.StatusOK, resp.StatusCode) + } + + b, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("GET method failed to return body: %s", err.Error()) + } + + if string(b) != `{"ok":1,"err_text":"","data":{"items":[{"product_id":6,"name":"Product Name","price":1234,"product_kind_id":33,"product_grp_id":113,"product_kind_name":"Kind 1","product_grp_code":"GRP1"},{"product_id":7,"name":"Product Name 2","price":1234,"product_kind_id":34,"product_grp_id":113,"product_kind_name":"Kind 2","product_grp_code":"GRP1"}]}}` { + t.Fatalf("GET method failed to return valid JSON") + } +} diff --git a/http_test.go b/http_test.go new file mode 100644 index 0000000..81b0c6e --- /dev/null +++ b/http_test.go @@ -0,0 +1,241 @@ +package crud + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "testing" +) + +var createdID int64 + +// TestHTTPHanlderPutMethodForValidations checks if HTTP endpoint returns validation failed error when PUT request with invalid input is made +func TestHTTPHandlerPutMethodForValidation(t *testing.T) { + j := `{ + "email": "invalid", + "first_name": "J", + "last_name": "S", + "key": "12" + }` + b := makePUTInsertRequest(j, http.StatusBadRequest, t) + if !strings.Contains(string(b), "validation_failed") { + t.Fatalf("PUT method for invalid request did not output validation_failed error text") + } +} + +// TestHTTPHandlerPutMethodForCreating tests if HTTP endpoint properly creates new object in the database, when PUT request is made, without object ID +func TestHTTPHandlerPutMethodForCreating(t *testing.T) { + j := `{ + "email": "test@example.com", + "first_name": "John", + "last_name": "Smith", + "key": "123456789012345678901234567890aa" + }` + b := makePUTInsertRequest(j, http.StatusCreated, t) + + id, flags, primaryEmail, emailSecondary, firstName, lastName, age, price, postCode, postCode2, password, createdByUserID, key, err := getRow() + if err != nil { + t.Fatalf("PUT method failed to insert struct to the table: %s", err.Error()) + } + if id == 0 || flags != 0 || primaryEmail != "test@example.com" || emailSecondary != "" || firstName != "John" || lastName != "Smith" || age != 0 || price != 0 || postCode != "" || postCode2 != "" || createdByUserID != 0 || key != "123456789012345678901234567890aa" || password != "" { + t.Fatalf("PUT method failed to insert struct to the table") + } + + r := NewHTTPResponse(1, "") + err = json.Unmarshal(b, &r) + if err != nil { + t.Fatalf("PUT method returned wrong json output, error marshaling: %s", err.Error()) + } + + if r.Data["id"].(float64) == 0 { + t.Fatalf("PUT method did not return id") + } + + createdID = int64(r.Data["id"].(float64)) +} + +// TestHTTPHandlerPutMethodForUpdating tests if HTTP endpoint successfully updates object details when PUT request with ID is being made +func TestHTTPHandlerPutMethodForUpdating(t *testing.T) { + j := `{ + "test_struct_flags": 8, + "email": "test11@example.com", + "email2": "test22@example.com", + "first_name": "John2", + "last_name": "Smith2", + "age": 39, + "price": 199, + "post_code": "22-222", + "post_code2": "33-333", + "password": "password123updated", + "created_by_user_id": 12, + "key": "123456789012345678901234567890nbh" + }` + _ = makePUTUpdateRequest(j, createdID, "", t) + + id, flags, primaryEmail, emailSecondary, firstName, lastName, age, price, postCode, postCode2, password, createdByUserID, key, err := getRow() + if err != nil { + t.Fatalf("PUT method failed to update struct to the table: %s", err.Error()) + } + // Only 2 fields should be updated: FirstName and LastName. Check the TestStruct_Update struct + if id == 0 || flags != 0 || primaryEmail != "test@example.com" || emailSecondary != "" || firstName != "John2" || lastName != "Smith2" || age != 0 || price != 0 || postCode != "" || postCode2 != "" || createdByUserID != 0 || key != "123456789012345678901234567890aa" || password != "" { + t.Fatalf("PUT method failed to update struct in the table") + } +} + +// TestHTTPHandlerPutMethodForUpdatingOnCustomEndpoint tests if HTTP endpoint successfully updates object when PUT request with ID +// is being made, and when the endpoint is a custom endpoint (it actually does not matter that much) +func TestHTTPHandlerPutMethodForUpdatingOnCustomEndpoint(t *testing.T) { + j := `{ + "test_struct_flags": 8, + "email": "test11@example.com", + "email2": "test22@example.com", + "first_name": "John2", + "last_name": "Smith2", + "age": 39, + "price": 444, + "post_code": "22-222", + "post_code2": "33-333", + "password": "password123updated", + "created_by_user_id": 12, + "key": "123456789012345678901234567890nbh" + }` + _ = makePUTUpdateRequest(j, createdID, httpURI2, t) + + id, flags, _, _, _, _, _, price, _, _, _, _, _, err := getRow() + if err != nil { + t.Fatalf("PUT method failed to update struct to the table: %s", err.Error()) + } + // Only Price field should be updated. Check the TestStruct_Update struct + if id == 0 || flags != 0 || price != 444 { + t.Fatalf(strconv.Itoa(price)) + t.Fatalf("PUT method on a custom endpoint failed to update struct in the table") + } + + j = `{ + "password": "duplicateme!" + }` + _ = makePUTUpdateRequest(j, createdID, httpURIPassFunc, t) + + id, flags, _, _, _, _, _, _, _, _, password, _, _, err := getRow() + if err != nil { + t.Fatalf("PUT method failed to update struct to the table: %s", err.Error()) + } + // Only password field should be updated, and its value should be a duplicated string that was passed. Check the TestStruct_UpdatePasswordWithFunc struct + if id == 0 || flags != 0 || password != "duplicateme!duplicateme!" { + t.Fatalf("PUT method on a custom endpoint failed to update struct with field using password function in the table (wrong value: %s)", password) + } + +} + +// TestHTTPHandlerGetMethodOnExisting checks if HTTP endpoint properly return object details, +// when GET request with object ID is made +func TestHTTPHandlerGetMethodOnExisting(t *testing.T) { + resp := makeGETReadRequest(createdID, t) + + if resp.StatusCode != http.StatusOK { + t.Fatalf("GET method returned wrong status code, want %d, got %d", http.StatusOK, resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("GET method failed") + } + + r := NewHTTPResponse(1, "") + err = json.Unmarshal(body, &r) + if err != nil { + t.Fatalf("GET method failed to return unmarshable JSON: %s", err.Error()) + } + if r.Data["item"].(map[string]interface{})["age"].(float64) != 0 { + t.Fatalf("GET method returned invalid values") + } + if r.Data["item"].(map[string]interface{})["price"].(float64) != 444 { + t.Fatalf("GET method returned invalid values") + } + if strings.Contains(string(body), "email2") { + t.Fatalf("GET method returned output with field that should have been hidden") + } + if strings.Contains(string(body), "post_code2") { + t.Fatalf("GET method returned output with field that should have been hidden") + } + if r.Data["item"].(map[string]interface{})["password"].(string) != "(hidden)" { + t.Fatalf("GET method should have hid a field value") + } + if r.Data["item"].(map[string]interface{})["first_name"].(string) == "(hidden)" { + t.Fatalf("GET method hid a field that should not be hidden") + } +} + +// TestHTTPHandlerDeleteMethod tests if HTTP endpoint removes object from the database, when DELETE request is made +func TestHTTPHandlerDeleteMethod(t *testing.T) { + makeDELETERequest(createdID, t) + + cnt, err2 := getRowCntById(createdID) + if err2 != nil { + t.Fatalf("DELETE handler failed to delete struct from the table") + } + if cnt > 0 { + t.Fatalf("DELETE handler failed to delete struct from the table") + } +} + +// TestHTTPHandlerGetMethodOnNonExisting checks HTTP endpoint response when making GET request with non-existing object ID +func TestHTTPHandlerGetMethodOnNonExisting(t *testing.T) { + resp := makeGETReadRequest(createdID, t) + + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("GET method returned wrong status code, want %d, got %d", http.StatusNotFound, resp.StatusCode) + } +} + +// TestHTTPHandlerGetMethodWithoutID tests if HTTP endpoint returns list of objects when GET request without ID +// is done; request contains filters, order and result limit +func TestHTTPHandlerGetMethodWithoutID(t *testing.T) { + truncateTable() + + ts := getTestStructWithData() + for i := 1; i <= 55; i++ { + ts.ID = 0 + // Key must be unique + ts.Key = fmt.Sprintf("%d%s", i, "123456789012345678901234567890") + ts.Age = ts.Age + 1 + ctl.orm.Save(ts) + } + b := makeGETListRequest(map[string]string{ + "limit": "10", + "offset": "20", + "order": "age", + "order_direction": "asc", + "filter_price": "444", + "filter_primary_email": "primary@example.com", + }, t) + + r := NewHTTPResponse(1, "") + err := json.Unmarshal(b, &r) + if err != nil { + t.Fatalf("GET method returned wrong json output, error marshaling: %s", err.Error()) + } + if len(r.Data["items"].([]interface{})) != 10 { + t.Fatalf("GET method returned invalid number of rows, want %d got %d", 10, len(r.Data["items"].([]interface{}))) + } + + if r.Data["items"].([]interface{})[2].(map[string]interface{})["age"].(float64) != 60 { + t.Fatalf("GET method returned invalid row, want %d got %f", 60, r.Data["items"].([]interface{})[2].(map[string]interface{})["age"].(float64)) + } + + if strings.Contains(string(b), "email2") { + t.Fatalf("GET method returned output with field that should have been hidden") + } + if strings.Contains(string(b), "post_code2") { + t.Fatalf("GET method returned output with field that should have been hidden") + } + if r.Data["items"].([]interface{})[2].(map[string]interface{})["password"].(string) != "(hidden)" { + t.Fatalf("GET method without id should have hid a field value") + } + if r.Data["items"].([]interface{})[2].(map[string]interface{})["first_name"].(string) == "(hidden)" { + t.Fatalf("GET method without id hid a field that should not be hidden") + } +} diff --git a/httpresponse.go b/httpresponse.go new file mode 100644 index 0000000..bc4b334 --- /dev/null +++ b/httpresponse.go @@ -0,0 +1,16 @@ +package crud + +// HTTPResponse is a base structure for all the HTTP responses from HTTP endpoints +type HTTPResponse struct { + OK int8 `json:"ok"` + ErrText string `json:"err_text"` + Data map[string]interface{} `json:"data"` +} + +// NewHTTPResponse returns new HTTPResponse object +func NewHTTPResponse(ok int8, errText string) HTTPResponse { + return HTTPResponse{ + OK: ok, + ErrText: errText, + } +} diff --git a/internal.go b/internal.go new file mode 100644 index 0000000..5495758 --- /dev/null +++ b/internal.go @@ -0,0 +1,73 @@ +package crud + +import ( + "fmt" +) + +// initHelpers creates all the Struct2sql objects. For HTTP endpoints, it is necessary to create these first +func (c *Controller) initHelpers(newObjFunc func() interface{}, options HandlerOptions) *ErrController { + obj := newObjFunc() + + var forceName string + if options.ForceName != "" { + forceName = options.ForceName + } + + cErr := c.orm.RegisterStruct(obj, nil, false, forceName, false) + if cErr != nil { + return &ErrController{ + Op: "RegisterStruct", + Err: fmt.Errorf("Error adding SQL generator: %w", cErr.Unwrap()), + } + } + + if options.CreateConstructor != nil { + cErr = c.orm.RegisterStruct(options.CreateConstructor(), obj, false, "", true) + if cErr != nil { + return &ErrController{ + Op: "RegisterStruct", + Err: fmt.Errorf("Error adding SQL generator: %w", cErr.Unwrap()), + } + } + } + + if options.ReadConstructor != nil { + cErr = c.orm.RegisterStruct(options.ReadConstructor(), obj, false, "", true) + if cErr != nil { + return &ErrController{ + Op: "RegisterStruct", + Err: fmt.Errorf("Error adding SQL generator: %w", cErr.Unwrap()), + } + } + } + + if options.UpdateConstructor != nil { + cErr = c.orm.RegisterStruct(options.UpdateConstructor(), obj, false, "", true) + if cErr != nil { + return &ErrController{ + Op: "RegisterStruct", + Err: fmt.Errorf("Error adding SQL generator: %w", cErr.Unwrap()), + } + } + } + + if options.ListConstructor != nil { + cErr = c.orm.RegisterStruct(options.ListConstructor(), obj, false, "", true) + if cErr != nil { + return &ErrController{ + Op: "RegisterStruct", + Err: fmt.Errorf("Error adding SQL generator: %w", cErr.Unwrap()), + } + } + } + + return nil +} + +func (c Controller) mapWithInterfacesToMapBool(m map[string]interface{}) map[string]bool { + o := map[string]bool{} + for k := range m { + o[k] = true + } + return o +} diff --git a/internal_http.go b/internal_http.go new file mode 100644 index 0000000..e73e3d0 --- /dev/null +++ b/internal_http.go @@ -0,0 +1,370 @@ +package crud + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "reflect" + "regexp" + "strconv" + "strings" +) + +func (c Controller) handleHTTPPut(w http.ResponseWriter, r *http.Request, newObjFunc func() interface{}, id string) { + body, err := io.ReadAll(r.Body) + if err != nil { + c.writeErrText(w, http.StatusInternalServerError, "cannot_read_request_body") + return + } + + objClone := newObjFunc() + + if id != "" { + err2 := c.orm.Load(objClone, id) + if err2 != nil { + c.writeErrText(w, http.StatusInternalServerError, "cannot_get_from_db") + return + } + if c.orm.GetObjIDValue(objClone) == 0 { + c.writeErrText(w, http.StatusNotFound, "not_found_in_db") + return + } + } else { + c.orm.ResetFields(objClone) + } + + err = json.Unmarshal(body, objClone) + if err != nil { + c.writeErrText(w, http.StatusBadRequest, "invalid_json") + return + } + + // Password fields when password function was passed + if c.passFunc != nil { + v := reflect.ValueOf(objClone) + s := v.Elem() + indir := reflect.Indirect(v) + typ := indir.Type() + for j := 0; j < s.NumField(); j++ { + f := s.Field(j) + fieldTag := typ.Field(j).Tag.Get(c.tagName) + gotPassField := false + if f.Kind() == reflect.String && fieldTag != "" { + fieldTags := strings.Split(fieldTag, " ") + for _, ft := range fieldTags { + if ft == "password" { + gotPassField = true + break + } + } + } + if gotPassField { + passVal := c.passFunc(f.String()) + s.Field(j).SetString(passVal) + } + } + } + + b, _, err := c.Validate(objClone, nil) + if !b || err != nil { + c.writeErrText(w, http.StatusBadRequest, "validation_failed") + return + } + + // refactor: fix this by checking if ModifiedAt and ModifiedBy fields exist + // refactor: also, do the login stuff just like in the ui without umbrella here + /*opts := stdb.SaveOptions{} + userId := r.Context().Value(umbrella.UmbrellaContextValue("UmbrellaUserID")) + rk := reflect.ValueOf(userId).Kind() + if rk == reflect.Int64 && userId.(int64) != 0 { + opts.ModifiedBy = userId.(int64) + opts.ModifiedAt = time.Now().Unix() + }*/ + + err2 := c.orm.Save(objClone) + if err2 != nil { + c.writeErrText(w, http.StatusInternalServerError, "cannot_save_to_db") + return + } + + if id != "" { + c.writeOK(w, http.StatusOK, map[string]interface{}{ + "id": c.orm.GetObjIDValue(objClone), + }) + } else { + c.writeOK(w, http.StatusCreated, map[string]interface{}{ + "id": c.orm.GetObjIDValue(objClone), + }) + } +} + +func (c Controller) handleHTTPGet(w http.ResponseWriter, r *http.Request, newObjFunc func() interface{}, id string) { + objClone := newObjFunc() + + hiddenFields := map[string]bool{} + v := reflect.ValueOf(objClone) + s := v.Elem() + indir := reflect.Indirect(v) + typ := indir.Type() + for j := 0; j < s.NumField(); j++ { + f := s.Field(j) + fieldTag := typ.Field(j).Tag.Get(c.tagName) + gotHiddenField := false + if f.Kind() == reflect.String && fieldTag != "" { + fieldTags := strings.Split(fieldTag, " ") + for _, ft := range fieldTags { + if ft == "hidden" { + gotHiddenField = true + break + } + } + } + if gotHiddenField { + hiddenFields[typ.Field(j).Name] = true + } + } + + if id != "" { + err := c.orm.Load(objClone, id) + if err != nil { + c.writeErrText(w, http.StatusInternalServerError, "cannot_get_from_db") + return + } + + // hide fields that are tagged with 'hidden' + for j := 0; j < s.NumField(); j++ { + f := s.Field(j) + if f.Kind() == reflect.String && hiddenFields[typ.Field(j).Name] { + s.Field(j).SetString("(hidden)") + } + } + + if c.orm.GetObjIDValue(objClone) == 0 { + c.writeErrText(w, http.StatusNotFound, "not_found_in_db") + return + } + + c.writeOK(w, http.StatusOK, map[string]interface{}{ + "item": objClone, + }) + + return + } + + // No id, get more elements + params := c.getParamsFromURI(r.RequestURI) + limit, _ := strconv.Atoi(params["limit"]) + offset, _ := strconv.Atoi(params["offset"]) + if limit < 1 { + limit = 10 + } + if offset < 0 { + offset = 0 + } + + order := []string{} + if params["order"] != "" { + order = append(order, params["order"]) + order = append(order, params["order_direction"]) + } + + filters := make(map[string]interface{}) + for k, v := range params { + if !strings.HasPrefix(k, "filter_") { + continue + } + k = k[7:] + fieldName, fieldValue, errF := c.uriFilterToFilter(objClone, k, v) + if errF == nil { + if fieldName != "" { + filters[fieldName] = fieldValue + } + continue + } + if errF.Op == "GetHelper" { + c.writeErrText(w, http.StatusInternalServerError, "get_helper") + return + } else { + c.writeErrText(w, http.StatusBadRequest, "invalid_filter") + return + } + } + + xobj, err1 := c.orm.Get(newObjFunc, order, limit, offset, filters, func(obj interface{}) interface{} { + v := reflect.ValueOf(obj) + s := v.Elem() + i := reflect.Indirect(v) + t := i.Type() + for j := 0; j < s.NumField(); j++ { + f := s.Field(j) + if f.Kind() == reflect.String && hiddenFields[t.Field(j).Name] { + s.Field(j).SetString("(hidden)") + } + } + return obj + }) + if err1 != nil { + if err1.IsInvalidFilters() { + c.writeErrText(w, http.StatusBadRequest, "invalid_filter_value") + return + } else { + c.writeErrText(w, http.StatusInternalServerError, "cannot_get_from_db") + return + } + } + + c.writeOK(w, http.StatusOK, map[string]interface{}{ + "items": xobj, + }) +} + +func (c Controller) handleHTTPDelete(w http.ResponseWriter, r *http.Request, newObjFunc func() interface{}, id string) { + if id == "" { + c.writeErrText(w, http.StatusBadRequest, "invalid_id") + return + } + + objClone := newObjFunc() + + err := c.orm.Load(objClone, id) + if err != nil { + c.writeErrText(w, http.StatusInternalServerError, "cannot_get_from_db") + return + } + if c.orm.GetObjIDValue(objClone) == 0 { + c.writeErrText(w, http.StatusNotFound, "not_found_in_db") + return + } + + err = c.orm.Delete(objClone) + if err != nil { + c.writeErrText(w, http.StatusInternalServerError, "cannot_delete_from_db") + return + } + + c.writeOK(w, http.StatusOK, map[string]interface{}{ + "id": id, + }) +} + +func (c Controller) getIDFromURI(uri string, w http.ResponseWriter) (string, bool) { + xs := strings.SplitN(uri, "?", 2) + if xs[0] == "" { + return "", true + } + matched, err := regexp.Match(`^[0-9]+$`, []byte(xs[0])) + if err != nil || !matched { + w.WriteHeader(http.StatusBadRequest) + w.Write(c.jsonError("invalid id")) + return "", false + } + return xs[0], true +} + +func (c Controller) getParamsFromURI(uri string) map[string]string { + o := make(map[string]string) + xs := strings.SplitN(uri, "?", 2) + if len(xs) < 2 || xs[1] == "" { + return o + } + xp := strings.SplitN(xs[1], "&", -1) + for _, p := range xp { + pv := strings.SplitN(p, "=", 2) + matched, err := regexp.Match(`^[0-9a-zA-Z_]+$`, []byte(pv[0])) + if len(pv) == 1 || err != nil || !matched { + continue + } + unesc, err := url.QueryUnescape(pv[1]) + if err != nil { + continue + } + o[pv[0]] = unesc + } + return o +} + +func (c Controller) jsonError(e string) []byte { + return []byte(fmt.Sprintf("{\"err\":\"%s\"}", e)) +} + +func (c Controller) jsonID(id int64) []byte { + return []byte(fmt.Sprintf("{\"id\":\"%d\"}", id)) +} + +func (c Controller) uriFilterToFilter(obj interface{}, filterName string, filterValue string) (string, interface{}, *ErrController) { + fieldName, cErr := c.orm.GetFieldNameFromDBCol(obj, filterName) + if cErr != nil { + return "", nil, &ErrController{ + Op: "GetDBCol", + Err: fmt.Errorf("Error getting field name from filter: %w", cErr), + } + } + + if fieldName == "" { + return "", nil, nil + } + + val := reflect.ValueOf(obj).Elem() + valueField := val.FieldByName(fieldName) + if valueField.Type().Name() == "int" { + filterInt, err := strconv.Atoi(filterValue) + if err != nil { + return "", nil, &ErrController{ + Op: "InvalidValue", + Err: fmt.Errorf("Error converting string to int: %w", err), + } + } + return fieldName, filterInt, nil + } + if valueField.Type().Name() == "int64" { + filterInt64, err := strconv.ParseInt(filterValue, 10, 64) + if err != nil { + return "", nil, &ErrController{ + Op: "InvalidValue", + Err: fmt.Errorf("Error converting string to int64: %w", err), + } + } + return fieldName, filterInt64, nil + } + if valueField.Type().Name() == "string" { + return fieldName, filterValue, nil + } + + return "", nil, nil +} + +func (c Controller) writeErrText(w http.ResponseWriter, status int, errText string) { + r := NewHTTPResponse(0, errText) + j, err := json.Marshal(r) + w.WriteHeader(status) + if err == nil { + w.Write(j) + } +} + +func (c Controller) writeOK(w http.ResponseWriter, status int, data map[string]interface{}) { + r := NewHTTPResponse(1, "") + r.Data = data + j, err := json.Marshal(r) + w.WriteHeader(status) + if err == nil { + w.Write(j) + } +} + +func (c *Controller) isStructOperationAllowed(r *http.Request, structName string, op int) bool { + allowedTypes := r.Context().Value(ContextValue(fmt.Sprintf("AllowedTypes_%d", op))) + if allowedTypes != nil { + v, ok := allowedTypes.(map[string]bool)[structName] + if !ok || !v { + v2, ok2 := allowedTypes.(map[string]bool)["all"] + if !ok2 || !v2 { + return false + } + } + } + + return true +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..7ac46e1 --- /dev/null +++ b/main.go @@ -0,0 +1,43 @@ +package crud + +import ( + "database/sql" +) + +// Controller is the main component that gets and saves objects in the database and generates CRUD HTTP handler +// that can be attached to an HTTP server. +type Controller struct { + orm ORM + tagName string + passFunc func(string) string +} + +type ControllerConfig struct { + TagName string + PasswordGenerator func(string) string + ORM ORM +} + +type ContextValue string + +// NewController returns new Controller object +func NewController(dbConn *sql.DB, tblPrefix string, cfg *ControllerConfig) *Controller { + c := &Controller{} + + c.tagName = "crud" + if cfg != nil && cfg.TagName != "" { + c.tagName = cfg.TagName + } + + if cfg != nil && cfg.PasswordGenerator != nil { + c.passFunc = cfg.PasswordGenerator + } + + if cfg != nil && cfg.ORM != nil { + c.orm = cfg.ORM + } else { + c.orm = newWrappedStruct2db(dbConn, tblPrefix, c.tagName) + } + + return c +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..3992d41 --- /dev/null +++ b/main_test.go @@ -0,0 +1,448 @@ +package crud + +import ( + "bytes" + "context" + "database/sql" + "fmt" + "io" + "log" + "net/http" + "net/url" + "os" + "testing" + "time" + + _ "github.com/lib/pq" + "github.com/ory/dockertest/v3" +) + +// Global vars used across all the tests +var dbUser = "gocrudtest" +var dbPass = "secret" +var dbName = "gocrud" +var dbConn *sql.DB + +var dockerPool *dockertest.Pool +var dockerResource *dockertest.Resource + +var httpPort = "32777" +var httpCancelCtx context.CancelFunc +var httpURI = "/v1/testobjects/" +var httpURI2 = "/v1/testobjects/price/" +var httpURIPassFunc = "/v1/testobjects/password/" +var httpURIJoined = "/v1/joined/" + +var ctl *Controller + +var testStructNewFunc func() interface{} +var testStructCreateNewFunc func() interface{} +var testStructReadNewFunc func() interface{} +var testStructUpdateNewFunc func() interface{} +var testStructListNewFunc func() interface{} +var testStructUpdatePriceNewFunc func() interface{} +var testStructUpdatePasswordWithFuncNewFunc func() interface{} +var testStructObj *TestStruct + +// Test struct for all the tests +type TestStruct struct { + ID int64 `json:"test_struct_id"` + Flags int64 `json:"test_struct_flags"` + + // Test email validation + PrimaryEmail string `json:"email" crud:"req"` + EmailSecondary string `json:"email2" crud:"req email"` + + // Test length validation + FirstName string `json:"first_name" crud:"req lenmin:2 lenmax:30"` + LastName string `json:"last_name" crud:"req lenmin:0 lenmax:255"` + + // Test int value validation + Age int `json:"age" crud:"valmin:1 valmax:120"` + Price int `json:"price" crud:"valmin:0 valmax:999"` + + // Test regular expression + PostCode string `json:"post_code" crud:"req lenmin:6 regexp:^[0-9]{2}\\-[0-9]{3}$"` + PostCode2 string `json:"post_code2" crud:"lenmin:6" crud_regexp:"^[0-9]{2}\\-[0-9]{3}$"` + + // Test HTTP endpoint tags + Password string `json:"password"` + CreatedByUserID int64 `json:"created_by_user_id" crud_val:"55"` + + // Test unique tag + Key string `json:"key" crud:"req uniq lenmin:30 lenmax:255"` +} + +// Test structs for HTTP endpoints +// Create +type TestStruct_Create struct { + ID int64 `json:"test_struct_id"` + PrimaryEmail string `json:"email" crud:"req"` + FirstName string `json:"first_name" crud:"req lenmin:2 lenmax:30"` + LastName string `json:"last_name" crud:"req lenmin:0 lenmax:255"` + Key string `json:"key" crud:"req uniq lenmin:30 lenmax:255"` +} + +type TestStruct_Update struct { + ID int64 `json:"test_struct_id"` + FirstName string `json:"first_name" crud:"req lenmin:2 lenmax:30"` + LastName string `json:"last_name" crud:"req lenmin:0 lenmax:255"` +} + +type TestStruct_UpdatePrice struct { + ID int64 `json:"test_struct_id"` + Price int `json:"price" crud:"valmin:0 valmax:999"` +} + +type TestStruct_UpdatePasswordWithFunc struct { + ID int64 `json:"test_struct_id"` + Password string `json:"password" crud:"valmin:0 valmax:999 password"` +} + +type TestStruct_Read struct { + ID int64 `json:"test_struct_id"` + PrimaryEmail string `json:"email"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Age int `json:"age"` + Price int `json:"price"` + PostCode string `json:"post_code"` + Password string `json:"password" crud:"hidden"` +} + +type TestStruct_List struct { + ID int64 `json:"test_struct_id"` + Price int `json:"price"` + PrimaryEmail string `json:"email" crud:"req email"` + FirstName string `json:"first_name"` + Age int `json:"age"` + Password string `json:"password" crud:"hidden"` +} + +func TestMain(m *testing.M) { + createDocker() + createController() + createDBStructure() + createHTTPServer() + + code := m.Run() + //removeDocker() + os.Exit(code) +} + +func createDocker() { + var err error + dockerPool, err = dockertest.NewPool("") + if err != nil { + log.Fatalf("Could not connect to docker: %s", err) + } + dockerResource, err = dockerPool.Run("postgres", "13", []string{"POSTGRES_PASSWORD=" + dbPass, "POSTGRES_USER=" + dbUser, "POSTGRES_DB=" + dbName}) + if err != nil { + log.Fatalf("Could not start resource: %s", err) + } + if err = dockerPool.Retry(func() error { + var err error + dbConn, err = sql.Open("postgres", fmt.Sprintf("host=localhost user=%s password=%s port=%s dbname=%s sslmode=disable", dbUser, dbPass, dockerResource.GetPort("5432/tcp"), dbName)) + if err != nil { + return err + } + return dbConn.Ping() + }); err != nil { + log.Fatalf("Could not connect to docker: %s", err) + } +} + +func createController() { + ctl = NewController(dbConn, "crud_", &ControllerConfig{ + TagName: "crud", + PasswordGenerator: func(p string) string { + return p + p + }, + }) + testStructNewFunc = func() interface{} { + return &TestStruct{} + } + testStructCreateNewFunc = func() interface{} { + return &TestStruct_Create{} + } + testStructUpdateNewFunc = func() interface{} { + return &TestStruct_Update{} + } + testStructReadNewFunc = func() interface{} { + return &TestStruct_Read{} + } + testStructListNewFunc = func() interface{} { + return &TestStruct_List{} + } + testStructUpdatePriceNewFunc = func() interface{} { + return &TestStruct_UpdatePrice{} + } + testStructUpdatePasswordWithFuncNewFunc = func() interface{} { + return &TestStruct_UpdatePasswordWithFunc{} + } + testStructObj = testStructNewFunc().(*TestStruct) +} + +func createDBStructure() { + ctl.orm.CreateTables(testStructObj) +} + +func getWrappedHTTPHandler(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := context.WithValue(r.Context(), "UserID", 123) + req := r.WithContext(ctx) + next.ServeHTTP(w, req) + }) +} + +func createHTTPServer() { + var ctx context.Context + ctx, httpCancelCtx = context.WithCancel(context.Background()) + go func(ctx context.Context) { + go func() { + http.Handle(httpURI, getWrappedHTTPHandler(ctl.Handler(httpURI, testStructNewFunc, HandlerOptions{ + CreateConstructor: testStructCreateNewFunc, + ReadConstructor: testStructReadNewFunc, + UpdateConstructor: testStructUpdateNewFunc, + ListConstructor: testStructListNewFunc, + }))) + http.Handle(httpURI2, ctl.Handler(httpURI2, testStructNewFunc, HandlerOptions{ + Operations: OpUpdate, + UpdateConstructor: testStructUpdatePriceNewFunc, + })) + http.Handle(httpURIPassFunc, ctl.Handler(httpURIPassFunc, testStructNewFunc, HandlerOptions{ + Operations: OpUpdate, + UpdateConstructor: testStructUpdatePasswordWithFuncNewFunc, + })) + http.Handle(httpURIJoined, ctl.Handler(httpURIJoined, func() interface{} { return &Product_WithDetails{} }, HandlerOptions{ + Operations: OpRead | OpList, + ForceName: "Product", + })) + http.ListenAndServe(":"+httpPort, nil) + }() + }(ctx) + time.Sleep(2 * time.Second) +} + +func removeDocker() { + dockerPool.Purge(dockerResource) +} + +func getTableNameCnt(tblName string) (int64, error) { + var cnt int64 + err := dbConn.QueryRow("SELECT COUNT(table_name) AS c FROM information_schema.tables WHERE table_schema = 'public' AND table_name = $1", tblName).Scan(&cnt) + return cnt, err +} + +func getRow() (int64, int64, string, string, string, string, int, int, string, string, string, int64, string, error) { + var id, flags, createdByUserID int64 + var primaryEmail, emailSecondary, firstName, lastName, postCode, postCode2, password, key string + var age, price int + err := dbConn.QueryRow("SELECT * FROM crud_test_structs ORDER BY test_struct_id DESC LIMIT 1").Scan(&id, &flags, &primaryEmail, &emailSecondary, &firstName, &lastName, &age, &price, &postCode, &postCode2, &password, &createdByUserID, &key) + return id, flags, primaryEmail, emailSecondary, firstName, lastName, age, price, postCode, postCode2, password, createdByUserID, key, err +} + +func getRowById(id int64) (int64, string, string, string, string, int, int, string, string, string, int64, string, error) { + var id2, flags, createdByUserID int64 + var primaryEmail, emailSecondary, firstName, lastName, postCode, postCode2, password, key string + var age, price int + err := dbConn.QueryRow(fmt.Sprintf("SELECT * FROM crud_test_structs WHERE test_struct_id = %d", id)).Scan(&id2, &flags, &primaryEmail, &emailSecondary, &firstName, &lastName, &age, &price, &postCode, &postCode2, &password, &createdByUserID, &key) + return flags, primaryEmail, emailSecondary, firstName, lastName, age, price, postCode, postCode2, password, createdByUserID, key, err +} + +func getRowCntById(id int64) (int64, error) { + var cnt int64 + err := dbConn.QueryRow(fmt.Sprintf("SELECT COUNT(*) AS c FROM crud_test_structs WHERE test_struct_id = %d", id)).Scan(&cnt) + return cnt, err +} + +func truncateTable() error { + _, err := dbConn.Exec("TRUNCATE TABLE crud_test_structs") + return err +} + +func getTestStructWithData() *TestStruct { + ts := testStructNewFunc().(*TestStruct) + ts.Flags = 4 + ts.PrimaryEmail = "primary@example.com" + ts.EmailSecondary = "secondary@example.com" + ts.FirstName = "John" + ts.LastName = "Smith" + ts.Age = 37 + ts.Price = 444 + ts.PostCode = "00-000" + ts.PostCode2 = "11-111" + ts.Password = "yyy" + ts.CreatedByUserID = 4 + ts.Key = fmt.Sprintf("12345679012345678901234567890%d", time.Now().UnixNano()) + return ts +} + +func areTestStructObjectSame(ts1 *TestStruct, ts2 *TestStruct) bool { + if ts1.Flags != ts2.Flags { + return false + } + if ts1.PrimaryEmail != ts2.PrimaryEmail { + return false + } + if ts1.EmailSecondary != ts2.EmailSecondary { + return false + } + if ts1.FirstName != ts2.FirstName { + return false + } + if ts1.LastName != ts2.LastName { + return false + } + if ts1.Age != ts2.Age { + return false + } + if ts1.Price != ts2.Price { + return false + } + if ts1.PostCode != ts2.PostCode { + return false + } + if ts1.PostCode2 != ts2.PostCode2 { + return false + } + if ts1.Password != ts2.Password { + return false + } + if ts1.CreatedByUserID != ts2.CreatedByUserID { + return false + } + if ts1.Key != ts2.Key { + return false + } + return true +} + +func makePUTInsertRequest(j string, status int, t *testing.T) []byte { + url := "http://localhost:" + httpPort + httpURI + req, err := http.NewRequest("PUT", url, bytes.NewReader([]byte(j))) + if err != nil { + t.Fatalf("PUT method failed on HTTP server with handler from GetHTTPHandler: %s", err.Error()) + } + c := &http.Client{} + resp, err := c.Do(req) + if err != nil { + t.Fatalf("PUT method failed on HTTP server with handler from GetHTTPHandler: %s", err.Error()) + } + if resp.StatusCode != status { + b, _ := io.ReadAll(resp.Body) + log.Printf("url: %s", url) + log.Printf("response body: %s", string(b)) + t.Fatalf("PUT method returned wrong status code, want %d, got %d", status, resp.StatusCode) + } + + b, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("PUT method failed to return body: %s", err.Error()) + } + + return b +} + +func makePUTUpdateRequest(j string, id int64, customURI string, t *testing.T) []byte { + uri := httpURI + if customURI != "" { + uri = customURI + } + + url := "http://localhost:" + httpPort + uri + fmt.Sprintf("%d", id) + req, err := http.NewRequest("PUT", url, bytes.NewReader([]byte(j))) + if err != nil { + t.Fatalf("PUT method failed on HTTP server with handler from GetHTTPHandler: %s", err.Error()) + } + c := &http.Client{} + resp, err := c.Do(req) + if err != nil { + t.Fatalf("PUT method failed on HTTP server with handler from GetHTTPHandler: %s", err.Error()) + } + if resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(resp.Body) + log.Printf("url: %s", url) + log.Printf("response body: %s", string(b)) + + t.Fatalf("PUT method returned wrong status code, want %d, got %d", http.StatusOK, resp.StatusCode) + } + + b, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("PUT method failed to return body: %s", err.Error()) + } + return b +} + +func makeDELETERequest(id int64, t *testing.T) { + req, err := http.NewRequest("DELETE", "http://localhost:"+httpPort+httpURI+fmt.Sprintf("%d", id), bytes.NewReader([]byte{})) + if err != nil { + t.Fatalf("DELETE method failed on HTTP server with handler from GetHTTPHandler: %s", err.Error()) + } + c := &http.Client{} + resp, err := c.Do(req) + if err != nil { + t.Fatalf("DELETE method failed on HTTP server with handler from GetHTTPHandler: %s", err.Error()) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("DELETE method returned wrong status code, want %d, got %d", http.StatusOK, resp.StatusCode) + } +} + +func makeGETReadRequest(id int64, t *testing.T) *http.Response { + req, err := http.NewRequest("GET", "http://localhost:"+httpPort+httpURI+fmt.Sprintf("%d", id), bytes.NewReader([]byte{})) + if err != nil { + t.Fatalf("GET method failed on HTTP server with handler from GetHTTPHandler: %s", err) + } + c := &http.Client{} + resp, err := c.Do(req) + if err != nil { + t.Fatalf("GET method failed on HTTP server with handler from GetHTTPHandler: %s", err) + } + return resp +} + +func makeGETListRequest(uriParams map[string]string, t *testing.T) []byte { + uriParamString := "" + for k, v := range uriParams { + uriParamString = addWithAmpersand(uriParamString, k+"="+url.QueryEscape(v)) + } + + req, err := http.NewRequest("GET", "http://localhost:"+httpPort+httpURI+"?"+uriParamString, bytes.NewReader([]byte{})) + if err != nil { + t.Fatalf("GET method failed on HTTP server with handler from GetHTTPHandler: %s", err) + } + c := &http.Client{} + resp, err := c.Do(req) + if err != nil { + t.Fatalf("GET method failed on HTTP server with handler from GetHTTPHandler: %s", err) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("GET method returned wrong status code, want %d, got %d", http.StatusOK, resp.StatusCode) + } + + b, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("GET method failed to return body: %s", err.Error()) + } + + return b +} + +func addWithAmpersand(s string, v string) string { + if s != "" { + s += "&" + } + s += v + return s +} + +func isInTheList(xs []string, v string) bool { + for _, s := range xs { + if s == v { + return true + } + } + return false +} diff --git a/orm.go b/orm.go new file mode 100644 index 0000000..d44a516 --- /dev/null +++ b/orm.go @@ -0,0 +1,142 @@ +package crud + +import ( + "database/sql" + + struct2db "github.com/go-phings/struct-db-postgres" +) + +type ORMError interface { + // IsInvalidFilters returns true when error is caused by invalid value of the filters when getting objects + IsInvalidFilters() bool + // Unwraps unwarps the original error + Unwrap() error +} + +type ORM interface { + // RegisterStruct initializes a specific object. ORMs often need to reflect the object to get the fields, build SQL queries etc. + // When doing that, certain things such as tags can be inherited from another object. This is in the scenario where there is a root object (eg. Product) that contains all the validation tags and + // another struct with less fields should be used as an input for API (eg. Product_WithoutCertainFields). In such case, there is no need to re-define tags such as validation. + // Parameter `forceNameForDB` allows forcing another struct name (which later is used for generating table name). + // This interface is based on the struct2db module and that module allows some cascade operations (such as delete or update). For this to work, and when certain fields are other structs, ORM must go + // deeper and initializes that guys as well. When setting useOnlyRootFromInheritedObj to true, it's being avoided. + RegisterStruct(obj interface{}, inheritFromObj interface{}, overwriteExisting bool, forceNameForDB string, useOnlyRootFromInheritedObj bool) ORMError + // CreateTables create database tables for struct instances + CreateTables(objs ...interface{}) error + // Load populates struct instance's field values with database values + Load(obj interface{}, id string) error + // Save stores (creates or updates) struct instance in the appropriate database table + Save(obj interface{}) error + // Delete removes struct instance from the database table + Delete(obj interface{}) error + // Get fetches data from the database and returns struct instances. Hence, it requires a constructor for the returned objects. Apart from the self-explanatory fields, filters in a format of (field name, any value) + // can be added, and each returned object (based on a database row) can be transformed into anything else. + Get(newObjFunc func() interface{}, order []string, limit int, offset int, filters map[string]interface{}, rowObjTransformFunc func(interface{}) interface{}) ([]interface{}, ORMError) + // GetFieldNameFromDBCol returns field name that is associated to a specified table column + GetFieldNameFromDBCol(obj interface{}, field string) (string, error) + // GetObjIDValue returns value of ID field for a specified struct instance + GetObjIDValue(obj interface{}) int64 + // ResetFields sets struct instance's field values to default ones + ResetFields(obj interface{}) +} + +// wrapped struct2db is an implementation of ORM interface that uses struct2db module +func newWrappedStruct2db(dbConn *sql.DB, tblPrefix string, tagName string) *wrappedStruct2db { + c := &wrappedStruct2db{ + dbConn: dbConn, + tblPrefix: tblPrefix, + tagName: tagName, + } + c.orm = struct2db.NewController(dbConn, tblPrefix, &struct2db.ControllerConfig{ + TagName: c.tagName, + }) + return c +} + +type ormErrorImpl struct { + op string + err error +} + +func (o *ormErrorImpl) IsInvalidFilters() bool { + return o.op == "ValidateFilters" +} + +func (o *ormErrorImpl) Error() string { + return o.err.Error() +} + +func (o *ormErrorImpl) Unwrap() error { + return o.err +} + +type wrappedStruct2db struct { + dbConn *sql.DB + tblPrefix string + tagName string + orm *struct2db.Controller +} + +func (w *wrappedStruct2db) CreateTables(objs ...interface{}) error { + return w.orm.CreateTables(objs...) +} + +func (w *wrappedStruct2db) Load(obj interface{}, id string) error { + return w.orm.Load(obj, id, struct2db.LoadOptions{}) +} + +func (w *wrappedStruct2db) Save(obj interface{}) error { + return w.orm.Save(obj, struct2db.SaveOptions{}) +} + +func (w *wrappedStruct2db) Delete(obj interface{}) error { + return w.orm.Delete(obj, struct2db.DeleteOptions{}) +} + +func (w *wrappedStruct2db) Get(newObjFunc func() interface{}, order []string, limit int, offset int, filters map[string]interface{}, rowObjTransformFunc func(interface{}) interface{}) ([]interface{}, ORMError) { + xobj, err := w.orm.Get(newObjFunc, struct2db.GetOptions{ + Order: order, + Limit: limit, + Offset: offset, + Filters: filters, + RowObjTransformFunc: rowObjTransformFunc, + }) + + if err != nil && err.Op == "ValidateFilters" { + return nil, &ormErrorImpl{ + op: err.Op, + } + } + + if err != nil { + return nil, &ormErrorImpl{ + op: err.Op, + err: err.Err, + } + } + + return xobj, nil +} + +func (w *wrappedStruct2db) GetFieldNameFromDBCol(obj interface{}, field string) (string, error) { + return w.orm.GetFieldNameFromDBCol(obj, field) +} + +func (w *wrappedStruct2db) GetObjIDValue(obj interface{}) int64 { + return w.orm.GetObjIDValue(obj) +} + +func (w *wrappedStruct2db) ResetFields(obj interface{}) { + w.orm.ResetFields(obj) +} + +func (w *wrappedStruct2db) RegisterStruct(obj interface{}, inheritFromObj interface{}, overwriteExisting bool, forceNameForDB string, useOnlyRootFromInheritedObj bool) ORMError { + err := w.orm.AddSQLGenerator(obj, inheritFromObj, overwriteExisting, forceNameForDB, useOnlyRootFromInheritedObj) + if err != nil { + return &ormErrorImpl{ + op: err.Op, + err: err.Err, + } + } + return nil +} diff --git a/validation.go b/validation.go new file mode 100644 index 0000000..81bdaa9 --- /dev/null +++ b/validation.go @@ -0,0 +1,91 @@ +package crud + +import ( + "reflect" + "regexp" + + validator "github.com/go-phings/struct-validator" +) + +// Validate checks object's fields. It returns result of validation as a bool and list of fields with invalid value +func (c *Controller) Validate(obj interface{}, filters map[string]interface{}) (bool, map[string]int, error) { + if filters != nil { + valid, failedFields := validator.Validate(obj, &validator.ValidationOptions{ + OverwriteTagName: "crud", + ValidateWhenSuffix: true, + OverwriteFieldValues: filters, + RestrictFields: c.mapWithInterfacesToMapBool(filters), + }) + return valid, failedFields, nil + } + + valid, failedFields := validator.Validate(obj, &validator.ValidationOptions{ + OverwriteTagName: "crud", + ValidateWhenSuffix: true, + }) + return valid, failedFields, nil +} + +// validateFieldRequired checks if field that is required has a value +func (c *Controller) validateFieldRequired(valueField reflect.Value, canBeZero bool) bool { + if valueField.Type().Name() == "string" && valueField.String() == "" { + return false + } + if valueField.Type().Name() == "int" && valueField.Int() == 0 && !canBeZero { + return false + } + if valueField.Type().Name() == "int64" && valueField.Int() == 0 && !canBeZero { + return false + } + return true +} + +// validateFieldLength checks string field's length +func (c *Controller) validateFieldLength(valueField reflect.Value, length [2]int) bool { + if valueField.Type().Name() != "string" { + return true + } + if length[0] > -1 && len(valueField.String()) < length[0] { + return false + } + if length[1] > -1 && len(valueField.String()) > length[1] { + return false + } + return true +} + +// validateFieldValue checks int field's value +func (c *Controller) validateFieldValue(valueField reflect.Value, value [2]int, minIsZero bool, maxIsZero bool) bool { + if valueField.Type().Name() != "int" && valueField.Type().Name() != "int64" { + return true + } + // Minimal value is 0 only when canBeZero is true; otherwise it's not defined + if ((minIsZero && value[0] == 0) || value[0] != 0) && valueField.Int() < int64(value[0]) { + return false + } + // Maximal value is 0 only when canBeZero is true; otherwise it's not defined + if ((maxIsZero && value[1] == 0) || value[1] != 0) && valueField.Int() > int64(value[1]) { + return false + } + return true +} + +// validateFieldEmail checks if email field has a valid value +func (c *Controller) validateFieldEmail(valueField reflect.Value) bool { + if valueField.Type().Name() != "string" { + return true + } + var emailRegex = regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+\\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$") + return emailRegex.MatchString(valueField.String()) +} + +// validateFieldRegExp checks if string field's value matches the regular expression +func (c *Controller) validateFieldRegExp(valueField reflect.Value, re *regexp.Regexp) bool { + if valueField.Type().Name() != "string" { + return true + } + if !re.MatchString(valueField.String()) { + return false + } + return true +} From f89c416bfff2834e88321c323be2a933ad22465e Mon Sep 17 00:00:00 2001 From: Mikolaj Gasior Date: Fri, 27 Dec 2024 22:44:54 +0100 Subject: [PATCH 2/5] Tidy up handling errors so things work --- err.go | 4 ++-- internal.go | 12 ++++++------ internal_http.go | 10 +++++----- main_test.go | 7 ------- orm.go | 45 ++++++++++++++++++++++++++++++++++----------- 5 files changed, 47 insertions(+), 31 deletions(-) diff --git a/err.go b/err.go index bbd9216..b0bb87c 100644 --- a/err.go +++ b/err.go @@ -7,11 +7,11 @@ type ErrController struct { Err error } -func (e *ErrController) Error() string { +func (e ErrController) Error() string { return e.Err.Error() } -func (e *ErrController) Unwrap() error { +func (e ErrController) Unwrap() error { return e.Err } diff --git a/internal.go b/internal.go index 5495758..9f42636 100644 --- a/internal.go +++ b/internal.go @@ -5,7 +5,7 @@ import ( ) // initHelpers creates all the Struct2sql objects. For HTTP endpoints, it is necessary to create these first -func (c *Controller) initHelpers(newObjFunc func() interface{}, options HandlerOptions) *ErrController { +func (c *Controller) initHelpers(newObjFunc func() interface{}, options HandlerOptions) error { obj := newObjFunc() var forceName string @@ -15,7 +15,7 @@ func (c *Controller) initHelpers(newObjFunc func() interface{}, options HandlerO cErr := c.orm.RegisterStruct(obj, nil, false, forceName, false) if cErr != nil { - return &ErrController{ + return ErrController{ Op: "RegisterStruct", Err: fmt.Errorf("Error adding SQL generator: %w", cErr.Unwrap()), } @@ -24,7 +24,7 @@ func (c *Controller) initHelpers(newObjFunc func() interface{}, options HandlerO if options.CreateConstructor != nil { cErr = c.orm.RegisterStruct(options.CreateConstructor(), obj, false, "", true) if cErr != nil { - return &ErrController{ + return ErrController{ Op: "RegisterStruct", Err: fmt.Errorf("Error adding SQL generator: %w", cErr.Unwrap()), } @@ -34,7 +34,7 @@ func (c *Controller) initHelpers(newObjFunc func() interface{}, options HandlerO if options.ReadConstructor != nil { cErr = c.orm.RegisterStruct(options.ReadConstructor(), obj, false, "", true) if cErr != nil { - return &ErrController{ + return ErrController{ Op: "RegisterStruct", Err: fmt.Errorf("Error adding SQL generator: %w", cErr.Unwrap()), } @@ -44,7 +44,7 @@ func (c *Controller) initHelpers(newObjFunc func() interface{}, options HandlerO if options.UpdateConstructor != nil { cErr = c.orm.RegisterStruct(options.UpdateConstructor(), obj, false, "", true) if cErr != nil { - return &ErrController{ + return ErrController{ Op: "RegisterStruct", Err: fmt.Errorf("Error adding SQL generator: %w", cErr.Unwrap()), } @@ -54,7 +54,7 @@ func (c *Controller) initHelpers(newObjFunc func() interface{}, options HandlerO if options.ListConstructor != nil { cErr = c.orm.RegisterStruct(options.ListConstructor(), obj, false, "", true) if cErr != nil { - return &ErrController{ + return ErrController{ Op: "RegisterStruct", Err: fmt.Errorf("Error adding SQL generator: %w", cErr.Unwrap()), } diff --git a/internal_http.go b/internal_http.go index e73e3d0..3352af5 100644 --- a/internal_http.go +++ b/internal_http.go @@ -183,7 +183,7 @@ func (c Controller) handleHTTPGet(w http.ResponseWriter, r *http.Request, newObj } continue } - if errF.Op == "GetHelper" { + if errF.(ErrController).Op == "GetHelper" { c.writeErrText(w, http.StatusInternalServerError, "get_helper") return } else { @@ -293,10 +293,10 @@ func (c Controller) jsonID(id int64) []byte { return []byte(fmt.Sprintf("{\"id\":\"%d\"}", id)) } -func (c Controller) uriFilterToFilter(obj interface{}, filterName string, filterValue string) (string, interface{}, *ErrController) { +func (c Controller) uriFilterToFilter(obj interface{}, filterName string, filterValue string) (string, interface{}, error) { fieldName, cErr := c.orm.GetFieldNameFromDBCol(obj, filterName) if cErr != nil { - return "", nil, &ErrController{ + return "", nil, ErrController{ Op: "GetDBCol", Err: fmt.Errorf("Error getting field name from filter: %w", cErr), } @@ -311,7 +311,7 @@ func (c Controller) uriFilterToFilter(obj interface{}, filterName string, filter if valueField.Type().Name() == "int" { filterInt, err := strconv.Atoi(filterValue) if err != nil { - return "", nil, &ErrController{ + return "", nil, ErrController{ Op: "InvalidValue", Err: fmt.Errorf("Error converting string to int: %w", err), } @@ -321,7 +321,7 @@ func (c Controller) uriFilterToFilter(obj interface{}, filterName string, filter if valueField.Type().Name() == "int64" { filterInt64, err := strconv.ParseInt(filterValue, 10, 64) if err != nil { - return "", nil, &ErrController{ + return "", nil, ErrController{ Op: "InvalidValue", Err: fmt.Errorf("Error converting string to int64: %w", err), } diff --git a/main_test.go b/main_test.go index 3992d41..a5e7b31 100644 --- a/main_test.go +++ b/main_test.go @@ -330,9 +330,6 @@ func makePUTInsertRequest(j string, status int, t *testing.T) []byte { t.Fatalf("PUT method failed on HTTP server with handler from GetHTTPHandler: %s", err.Error()) } if resp.StatusCode != status { - b, _ := io.ReadAll(resp.Body) - log.Printf("url: %s", url) - log.Printf("response body: %s", string(b)) t.Fatalf("PUT method returned wrong status code, want %d, got %d", status, resp.StatusCode) } @@ -361,10 +358,6 @@ func makePUTUpdateRequest(j string, id int64, customURI string, t *testing.T) [] t.Fatalf("PUT method failed on HTTP server with handler from GetHTTPHandler: %s", err.Error()) } if resp.StatusCode != http.StatusOK { - b, _ := io.ReadAll(resp.Body) - log.Printf("url: %s", url) - log.Printf("response body: %s", string(b)) - t.Fatalf("PUT method returned wrong status code, want %d, got %d", http.StatusOK, resp.StatusCode) } diff --git a/orm.go b/orm.go index d44a516..8fe0355 100644 --- a/orm.go +++ b/orm.go @@ -2,6 +2,7 @@ package crud import ( "database/sql" + "errors" struct2db "github.com/go-phings/struct-db-postgres" ) @@ -11,6 +12,8 @@ type ORMError interface { IsInvalidFilters() bool // Unwraps unwarps the original error Unwrap() error + // Error returns error string + Error() string } type ORM interface { @@ -58,15 +61,15 @@ type ormErrorImpl struct { err error } -func (o *ormErrorImpl) IsInvalidFilters() bool { +func (o ormErrorImpl) IsInvalidFilters() bool { return o.op == "ValidateFilters" } -func (o *ormErrorImpl) Error() string { +func (o ormErrorImpl) Error() string { return o.err.Error() } -func (o *ormErrorImpl) Unwrap() error { +func (o ormErrorImpl) Unwrap() error { return o.err } @@ -78,19 +81,35 @@ type wrappedStruct2db struct { } func (w *wrappedStruct2db) CreateTables(objs ...interface{}) error { - return w.orm.CreateTables(objs...) + err := w.orm.CreateTables(objs...) + if err != nil { + return errors.New("create table failed") + } + return nil } func (w *wrappedStruct2db) Load(obj interface{}, id string) error { - return w.orm.Load(obj, id, struct2db.LoadOptions{}) + err := w.orm.Load(obj, id, struct2db.LoadOptions{}) + if err != nil { + return errors.New("load failed") + } + return nil } func (w *wrappedStruct2db) Save(obj interface{}) error { - return w.orm.Save(obj, struct2db.SaveOptions{}) + err := w.orm.Save(obj, struct2db.SaveOptions{}) + if err != nil { + return errors.New("save failed") + } + return nil } func (w *wrappedStruct2db) Delete(obj interface{}) error { - return w.orm.Delete(obj, struct2db.DeleteOptions{}) + err := w.orm.Delete(obj, struct2db.DeleteOptions{}) + if err != nil { + return errors.New("delete failed") + } + return nil } func (w *wrappedStruct2db) Get(newObjFunc func() interface{}, order []string, limit int, offset int, filters map[string]interface{}, rowObjTransformFunc func(interface{}) interface{}) ([]interface{}, ORMError) { @@ -103,13 +122,13 @@ func (w *wrappedStruct2db) Get(newObjFunc func() interface{}, order []string, li }) if err != nil && err.Op == "ValidateFilters" { - return nil, &ormErrorImpl{ + return nil, ormErrorImpl{ op: err.Op, } } if err != nil { - return nil, &ormErrorImpl{ + return nil, ormErrorImpl{ op: err.Op, err: err.Err, } @@ -119,7 +138,11 @@ func (w *wrappedStruct2db) Get(newObjFunc func() interface{}, order []string, li } func (w *wrappedStruct2db) GetFieldNameFromDBCol(obj interface{}, field string) (string, error) { - return w.orm.GetFieldNameFromDBCol(obj, field) + s, e := w.orm.GetFieldNameFromDBCol(obj, field) + if e != nil { + return "", errors.New("error getting field name from db col") + } + return s, nil } func (w *wrappedStruct2db) GetObjIDValue(obj interface{}) int64 { @@ -133,7 +156,7 @@ func (w *wrappedStruct2db) ResetFields(obj interface{}) { func (w *wrappedStruct2db) RegisterStruct(obj interface{}, inheritFromObj interface{}, overwriteExisting bool, forceNameForDB string, useOnlyRootFromInheritedObj bool) ORMError { err := w.orm.AddSQLGenerator(obj, inheritFromObj, overwriteExisting, forceNameForDB, useOnlyRootFromInheritedObj) if err != nil { - return &ormErrorImpl{ + return ormErrorImpl{ op: err.Op, err: err.Err, } From 74991b1400ac8163f24f2f82c20614b63fc461ee Mon Sep 17 00:00:00 2001 From: Mikolaj Gasior Date: Fri, 27 Dec 2024 23:36:23 +0100 Subject: [PATCH 3/5] Bump struct-db-postgres version --- README.md | 2 +- go.mod | 9 ++------- go.sum | 18 ++++-------------- orm.go | 12 ++++++------ 4 files changed, 13 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index ab7e957..a8c5209 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ in the database. ``` import ( - crud "github.com/mikolajgs/prototyping/pkg/rest-api" + crud "github.com/go-phings/crud" ) ``` diff --git a/go.mod b/go.mod index 10f9238..38705b2 100644 --- a/go.mod +++ b/go.mod @@ -3,11 +3,9 @@ module github.com/go-phings/crud go 1.23.4 require ( - github.com/go-phings/struct-db-postgres v0.6.0 - github.com/go-phings/struct-sql-postgres v0.6.0 + github.com/go-phings/struct-db-postgres v0.7.0 github.com/go-phings/struct-validator v0.4.7 github.com/lib/pq v1.10.9 - github.com/mikolajgs/prototyping/pkg/umbrella v0.0.0-20241129204714-bf573dcaf2c8 github.com/ory/dockertest/v3 v3.11.0 ) @@ -18,15 +16,13 @@ require ( github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/containerd/continuity v0.4.3 // indirect - github.com/dgrijalva/jwt-go v3.2.0+incompatible // indirect github.com/docker/cli v26.1.4+incompatible // indirect github.com/docker/docker v27.1.1+incompatible // indirect github.com/docker/go-connections v0.5.0 // indirect github.com/docker/go-units v0.5.0 // indirect + github.com/go-phings/struct-sql-postgres v0.7.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect - github.com/google/uuid v1.6.0 // indirect - github.com/mikolajgs/prototyping v0.2.1 // indirect github.com/mikolajgs/struct-validator v0.4.7 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect @@ -39,7 +35,6 @@ require ( github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect github.com/xeipuuv/gojsonschema v1.2.0 // indirect - golang.org/x/crypto v0.29.0 // indirect golang.org/x/sys v0.27.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect ) diff --git a/go.sum b/go.sum index 3619fb0..00e4cff 100644 --- a/go.sum +++ b/go.sum @@ -17,8 +17,6 @@ github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr 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/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= -github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/docker/cli v26.1.4+incompatible h1:I8PHdc0MtxEADqYJZvhBrW9bo8gawKwwenxRM7/rLu8= github.com/docker/cli v26.1.4+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/docker v27.1.1+incompatible h1:hO/M4MtV36kzKldqnA37IWhebRA+LnqqcqDja6kVaKY= @@ -27,10 +25,10 @@ github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= -github.com/go-phings/struct-db-postgres v0.6.0 h1:EbxRIrkwF2cDdTYr5TUJ2Ot3N+xSvKUxY4brJGIuloA= -github.com/go-phings/struct-db-postgres v0.6.0/go.mod h1:Yw7QdVcY6NZGp/gbn4sVGZLiCJF7YZR9mFx2HuWS+i0= -github.com/go-phings/struct-sql-postgres v0.6.0 h1:GvaiSnywCnA4RtRfQEkYOtNwnykKHW158if0Yoi8jCs= -github.com/go-phings/struct-sql-postgres v0.6.0/go.mod h1:bII8gzJnuAn7Ht+NEmHnyieu53EYnHhGg5EeY893yeE= +github.com/go-phings/struct-db-postgres v0.7.0 h1:s3vWoPpEni1HzGF68L8b0cFO/dl8iQeWQ0jYkQhcVLs= +github.com/go-phings/struct-db-postgres v0.7.0/go.mod h1:Yw7QdVcY6NZGp/gbn4sVGZLiCJF7YZR9mFx2HuWS+i0= +github.com/go-phings/struct-sql-postgres v0.7.0 h1:E18btNpiBWkgMJdYLXjKv7qXOAJzrjfTu3nySynKDWs= +github.com/go-phings/struct-sql-postgres v0.7.0/go.mod h1:bII8gzJnuAn7Ht+NEmHnyieu53EYnHhGg5EeY893yeE= github.com/go-phings/struct-validator v0.4.7 h1:qHCkn2ppOnzRxzE3+UCXD2+4bMp4xWtEcl5S/TKebRo= github.com/go-phings/struct-validator v0.4.7/go.mod h1:i+WCf5KGFnxiGdrtqsysAaIACvE5K+h5X/R8gPJQPB0= github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= @@ -41,16 +39,10 @@ github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= -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/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= -github.com/mikolajgs/prototyping v0.2.1 h1:bkC/3Td5A6QtEHfv66i36Jamjj7zJI/T5zUoyC+KvTs= -github.com/mikolajgs/prototyping v0.2.1/go.mod h1:1O3vsOStgBfkTV6/y7bywkoySfPHt3lfFT74fQe6iqk= -github.com/mikolajgs/prototyping/pkg/umbrella v0.0.0-20241129204714-bf573dcaf2c8 h1:rJKU3AT0bChaCe8B53J+zPOT6TzicU4QlDW/uhial00= -github.com/mikolajgs/prototyping/pkg/umbrella v0.0.0-20241129204714-bf573dcaf2c8/go.mod h1:rOkaJr2iIHXW3WexmwqqXRVEcC9dmHnV34II60WKOcM= github.com/mikolajgs/struct-validator v0.4.7 h1:6kBLsnBqC5KQpwY07n3yiqFUK3hm+f4KuHcYSceN4kY= github.com/mikolajgs/struct-validator v0.4.7/go.mod h1:Ks0Lm870PpN0ZuQ+LDKYhCazSNyN3zvX5NrFxwlS69g= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= @@ -90,8 +82,6 @@ github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9dec golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ= -golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= diff --git a/orm.go b/orm.go index 8fe0355..9156ace 100644 --- a/orm.go +++ b/orm.go @@ -121,16 +121,16 @@ func (w *wrappedStruct2db) Get(newObjFunc func() interface{}, order []string, li RowObjTransformFunc: rowObjTransformFunc, }) - if err != nil && err.Op == "ValidateFilters" { + if err != nil && err.(struct2db.ErrController).Op == "ValidateFilters" { return nil, ormErrorImpl{ - op: err.Op, + op: err.(struct2db.ErrController).Op, } } if err != nil { return nil, ormErrorImpl{ - op: err.Op, - err: err.Err, + op: err.(struct2db.ErrController).Op, + err: err.(struct2db.ErrController).Err, } } @@ -157,8 +157,8 @@ func (w *wrappedStruct2db) RegisterStruct(obj interface{}, inheritFromObj interf err := w.orm.AddSQLGenerator(obj, inheritFromObj, overwriteExisting, forceNameForDB, useOnlyRootFromInheritedObj) if err != nil { return ormErrorImpl{ - op: err.Op, - err: err.Err, + op: err.(struct2db.ErrController).Op, + err: err.(struct2db.ErrController).Err, } } return nil From d48b4021523db3ff93f11b838f34b4da5c72f661 Mon Sep 17 00:00:00 2001 From: Mikolaj Gasior Date: Fri, 27 Dec 2024 23:39:29 +0100 Subject: [PATCH 4/5] Remove unused umbrella code and comments --- http.go | 2 -- internal_http.go | 10 ---------- 2 files changed, 12 deletions(-) diff --git a/http.go b/http.go index 0bb64c3..1ae3357 100644 --- a/http.go +++ b/http.go @@ -14,8 +14,6 @@ type HandlerOptions struct { } // Values for CRUD operations -// Same as in the "umbrella" package: http://github.com/mikolajgs/prototyping/pkg/umbrella -// refactor: work on these guys to be different? const OpAll = 0 const OpRead = 16 const OpUpdate = 32 diff --git a/internal_http.go b/internal_http.go index 3352af5..bd0b428 100644 --- a/internal_http.go +++ b/internal_http.go @@ -73,16 +73,6 @@ func (c Controller) handleHTTPPut(w http.ResponseWriter, r *http.Request, newObj return } - // refactor: fix this by checking if ModifiedAt and ModifiedBy fields exist - // refactor: also, do the login stuff just like in the ui without umbrella here - /*opts := stdb.SaveOptions{} - userId := r.Context().Value(umbrella.UmbrellaContextValue("UmbrellaUserID")) - rk := reflect.ValueOf(userId).Kind() - if rk == reflect.Int64 && userId.(int64) != 0 { - opts.ModifiedBy = userId.(int64) - opts.ModifiedAt = time.Now().Unix() - }*/ - err2 := c.orm.Save(objClone) if err2 != nil { c.writeErrText(w, http.StatusInternalServerError, "cannot_save_to_db") From e2c7c88ff64b3f7b0aa67a77a5cabd8b67b7e0cf Mon Sep 17 00:00:00 2001 From: Mikolaj Gasior Date: Fri, 27 Dec 2024 23:40:40 +0100 Subject: [PATCH 5/5] Bump version number --- version.go | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 version.go diff --git a/version.go b/version.go new file mode 100644 index 0000000..3c42daa --- /dev/null +++ b/version.go @@ -0,0 +1,3 @@ +package crud + +const VERSION="0.7.0"