├── .gitattributes ├── .github └── workflows │ └── qa.yml ├── .gitignore ├── .golangci.toml ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── bench ├── DataWithGen_schema_test.go ├── bench_test.go └── testdata.json ├── cmd └── schemagen │ ├── README.md │ ├── lock.go │ ├── main.go │ ├── path.go │ └── validate.go ├── constraint └── constraint.go ├── encoding └── json │ ├── json.go │ └── json_test.go ├── examples ├── codegen │ ├── User_schema.go │ ├── invalid_data.json │ ├── main.go │ └── valid_data.json ├── parse-grpc │ ├── main.go │ └── pb │ │ ├── example.pb.go │ │ ├── example.proto │ │ └── gen.go ├── parse │ └── main.go └── tour │ └── main.go ├── go.mod ├── go.sum ├── internal ├── iso │ ├── countries.csv │ ├── countries.go │ ├── currencies.csv │ ├── currencies.go │ ├── gen.go │ ├── gen.py │ ├── languages.csv │ └── languages.go ├── reflectwalk │ ├── walk.go │ └── walk_test.go ├── testutil │ └── util.go ├── typeconv │ ├── typeconv.go │ └── typeconv_test.go └── uuid │ └── uuid.go ├── justfile ├── optional ├── binary.go ├── custom.go ├── gob.go ├── json.go ├── optional.go ├── optional_example_test.go ├── optional_test.go ├── sql.go └── text.go ├── parse ├── UserWithGen_schema_test.go ├── error.go ├── options.go ├── parse.go └── parse_test.go ├── required ├── binary.go ├── custom.go ├── gob.go ├── json.go ├── required.go ├── required_example_test.go ├── required_test.go ├── sql.go └── text.go ├── schema.go ├── validate ├── charset │ ├── charset.go │ └── charset_test.go ├── error.go ├── impl.go ├── validate.go ├── validators.go └── validators_test.go ├── validators.md ├── validators.py └── validators.toml /.gitattributes: -------------------------------------------------------------------------------- 1 | *.go text eol=lf -------------------------------------------------------------------------------- /.github/workflows/qa.yml: -------------------------------------------------------------------------------- 1 | name: Quality Assurance 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - master 8 | pull_request: 9 | 10 | jobs: 11 | test: 12 | name: Test 13 | 14 | runs-on: ${{ matrix.os }} 15 | strategy: 16 | fail-fast: false 17 | matrix: 18 | go: [stable] 19 | os: [ubuntu-latest, macos-latest, windows-latest] 20 | 21 | steps: 22 | - uses: actions/checkout@v4 23 | 24 | - name: Set up Go 25 | uses: actions/setup-go@v5 26 | with: 27 | go-version: ${{ matrix.go }} 28 | 29 | - name: Test 30 | run: go test -v ./... 31 | 32 | lint: 33 | name: Lint 34 | 35 | runs-on: ${{ matrix.os }} 36 | strategy: 37 | fail-fast: false 38 | matrix: 39 | go: [stable] 40 | os: [ubuntu-latest, macos-latest, windows-latest] 41 | 42 | steps: 43 | - uses: actions/checkout@v4 44 | 45 | - name: Set up Go 46 | uses: actions/setup-go@v5 47 | with: 48 | go-version: ${{ matrix.go }} 49 | 50 | - name: lint 51 | uses: golangci/golangci-lint-action@v8 52 | with: 53 | version: v2.1 54 | args: --tests=false 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | coverage.html 2 | -------------------------------------------------------------------------------- /.golangci.toml: -------------------------------------------------------------------------------- 1 | version = "2" 2 | 3 | [formatters] 4 | enable = ["gci", "gofmt", "gofumpt", "goimports", "golines"] 5 | 6 | [formatters.exclusions] 7 | generated = "strict" 8 | 9 | [linters] 10 | default = "all" 11 | disable = ["ireturn", "depguard", "err113", "exhaustive", "exhaustruct", "wrapcheck", "varnamelen", "mnd", "godox"] 12 | 13 | [[linters.exclusions.rules]] 14 | path = "examples/" 15 | linters = ["forbidigo", "wsl", "revive", "godot", "funlen"] 16 | 17 | [[linters.exclusions.rules]] 18 | path = "internal/uuid/" 19 | linters = ["gochecknoglobals"] 20 | 21 | [[linters.exclusions.rules]] 22 | linters = ["revive"] 23 | text = "exported:" 24 | 25 | [[linters.exclusions.rules]] 26 | path = "internal/" 27 | linters = ["revive"] 28 | text = "package-comments:" 29 | 30 | [[linters.exclusions.rules]] 31 | path = "cmd/" 32 | linters = ["revive"] 33 | text = "package-comments:" 34 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thank you for your interest! 4 | 5 | ## Prerequisite 6 | 7 | Install the following programs 8 | 9 | - `go` - Of course =) [installation instructions](https://go.dev/doc/install) 10 | - `just` - [Just a command runner](https://github.com/casey/just) 11 | - `python3` - latest stable version (tested with 3.13). No dependencies are needed. This is for code generation. 12 | - `golangci-lint` - [linter and formatter for go](https://golangci-lint.run/welcome/install/) 13 | 14 | To contribute follow the following steps: 15 | 16 | 1. Fork this repository. 17 | 2. Make your changes. 18 | 3. Run `just` command (without any arguments). Fix errors it emits, if any. 19 | 4. Push your changes to your fork. 20 | 5. Make PR. 21 | 6. You rock! 22 | 23 | ## Adding new validators 24 | 25 | > If you have any questions after reading this section feel free to open an issue - I will be happy to answer. 26 | 27 | Validators meta information are stored in [validators.toml](./validators.toml). 28 | 29 | This is done so that comment strings and documentation are generated from 30 | a single source of truth to avoid typos and manual work. 31 | 32 | After changing this file run: 33 | 34 | ```sh 35 | just generate 36 | ``` 37 | 38 | After that change [validate/impl.go](./validate/impl.go) file to add `Validate` method for your new validator. 39 | 40 | Again, if you have any questions - feel free to open an issue. 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2025 metafates 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /bench/DataWithGen_schema_test.go: -------------------------------------------------------------------------------- 1 | // Code generated by schemagen; DO NOT EDIT. 2 | 3 | package bench 4 | 5 | import ( 6 | "fmt" 7 | "github.com/metafates/schema/required" 8 | "github.com/metafates/schema/validate" 9 | ) 10 | 11 | // Ensure that [DataWithGen] type was not changed 12 | func _() { 13 | type locked []struct { 14 | ID required.Custom[string, validate.NonZero[string]] `json:"_id"` 15 | Index int `json:"index"` 16 | GUID string `json:"guid"` 17 | IsActive bool `json:"isActive"` 18 | Balance string `json:"balance"` 19 | Picture string `json:"picture"` 20 | Age required.Custom[int, validate.Positive[int]] `json:"age"` 21 | EyeColor string `json:"eyeColor"` 22 | Name string `json:"name"` 23 | Gender string `json:"gender"` 24 | Company string `json:"company"` 25 | Email string `json:"email"` 26 | Phone string `json:"phone"` 27 | Address string `json:"address"` 28 | About string `json:"about"` 29 | Registered string `json:"registered"` 30 | Latitude required.Custom[float64, validate.Latitude[float64]] `json:"latitude"` 31 | Longitude required.Custom[float64, validate.Longitude[float64]] `json:"longitude"` 32 | Tags []string `json:"tags"` 33 | Friends []struct { 34 | ID int `json:"id"` 35 | Name string `json:"name"` 36 | } `json:"friends"` 37 | Greeting string `json:"greeting"` 38 | FavoriteFruit string `json:"favoriteFruit"` 39 | } 40 | var v DataWithGen 41 | // Compiler error signifies that the type definition have changed. 42 | // Re-run the schemagen command to regenerate this file. 43 | _ = locked(v) 44 | } 45 | 46 | // TypeValidate implements the [validate.TypeValidateable] interface. 47 | func (x DataWithGen) TypeValidate() error { 48 | for i0 := range x { 49 | { 50 | err0 := validate.Validate(&x[i0].ID) 51 | if err0 != nil { 52 | return validate.ValidationError{Inner: err0}.WithPath(fmt.Sprintf("[%v].ID", i0)) 53 | } 54 | err1 := validate.Validate(&x[i0].Age) 55 | if err1 != nil { 56 | return validate.ValidationError{Inner: err1}.WithPath(fmt.Sprintf("[%v].Age", i0)) 57 | } 58 | err2 := validate.Validate(&x[i0].Latitude) 59 | if err2 != nil { 60 | return validate.ValidationError{Inner: err2}.WithPath(fmt.Sprintf("[%v].Latitude", i0)) 61 | } 62 | err3 := validate.Validate(&x[i0].Longitude) 63 | if err3 != nil { 64 | return validate.ValidationError{Inner: err3}.WithPath(fmt.Sprintf("[%v].Longitude", i0)) 65 | } 66 | } 67 | } 68 | return nil 69 | } 70 | -------------------------------------------------------------------------------- /bench/bench_test.go: -------------------------------------------------------------------------------- 1 | package bench 2 | 3 | import ( 4 | _ "embed" 5 | "encoding/json" 6 | "testing" 7 | 8 | "github.com/metafates/schema/required" 9 | "github.com/metafates/schema/validate" 10 | ) 11 | 12 | //go:embed testdata.json 13 | var testdata []byte 14 | 15 | //go:generate schemagen -type DataWithGen 16 | 17 | type DataWithGen []struct { 18 | ID required.NonZero[string] `json:"_id"` 19 | Index int `json:"index"` 20 | GUID string `json:"guid"` 21 | IsActive bool `json:"isActive"` 22 | Balance string `json:"balance"` 23 | Picture string `json:"picture"` 24 | Age required.Positive[int] `json:"age"` 25 | EyeColor string `json:"eyeColor"` 26 | Name string `json:"name"` 27 | Gender string `json:"gender"` 28 | Company string `json:"company"` 29 | Email string `json:"email"` 30 | Phone string `json:"phone"` 31 | Address string `json:"address"` 32 | About string `json:"about"` 33 | Registered string `json:"registered"` 34 | Latitude required.Latitude[float64] `json:"latitude"` 35 | Longitude required.Longitude[float64] `json:"longitude"` 36 | Tags []string `json:"tags"` 37 | Friends []struct { 38 | ID int `json:"id"` 39 | Name string `json:"name"` 40 | } `json:"friends"` 41 | Greeting string `json:"greeting"` 42 | FavoriteFruit string `json:"favoriteFruit"` 43 | } 44 | 45 | type Data []struct { 46 | ID required.NonZero[string] `json:"_id"` 47 | Index int `json:"index"` 48 | GUID string `json:"guid"` 49 | IsActive bool `json:"isActive"` 50 | Balance string `json:"balance"` 51 | Picture string `json:"picture"` 52 | Age required.Positive[int] `json:"age"` 53 | EyeColor string `json:"eyeColor"` 54 | Name string `json:"name"` 55 | Gender string `json:"gender"` 56 | Company string `json:"company"` 57 | Email string `json:"email"` 58 | Phone string `json:"phone"` 59 | Address string `json:"address"` 60 | About string `json:"about"` 61 | Registered string `json:"registered"` 62 | Latitude required.Latitude[float64] `json:"latitude"` 63 | Longitude required.Longitude[float64] `json:"longitude"` 64 | Tags []string `json:"tags"` 65 | Friends []struct { 66 | ID int `json:"id"` 67 | Name string `json:"name"` 68 | } `json:"friends"` 69 | Greeting string `json:"greeting"` 70 | FavoriteFruit string `json:"favoriteFruit"` 71 | } 72 | 73 | func BenchmarkUnmarshalJSON(b *testing.B) { 74 | b.Run("reflection", func(b *testing.B) { 75 | b.Run("with validation", func(b *testing.B) { 76 | for b.Loop() { 77 | var data Data 78 | 79 | if err := json.Unmarshal(testdata, &data); err != nil { 80 | b.Fatal(err) 81 | } 82 | 83 | if err := validate.Validate(&data); err != nil { 84 | b.Fatal(err) 85 | } 86 | } 87 | }) 88 | 89 | b.Run("without validation", func(b *testing.B) { 90 | for b.Loop() { 91 | var data Data 92 | 93 | if err := json.Unmarshal(testdata, &data); err != nil { 94 | b.Fatal(err) 95 | } 96 | } 97 | }) 98 | }) 99 | 100 | b.Run("codegen", func(b *testing.B) { 101 | b.Run("with validation", func(b *testing.B) { 102 | for b.Loop() { 103 | var data DataWithGen 104 | 105 | if err := json.Unmarshal(testdata, &data); err != nil { 106 | b.Fatal(err) 107 | } 108 | 109 | if err := validate.Validate(&data); err != nil { 110 | b.Fatal(err) 111 | } 112 | } 113 | }) 114 | 115 | b.Run("without validation", func(b *testing.B) { 116 | for b.Loop() { 117 | var data DataWithGen 118 | 119 | if err := json.Unmarshal(testdata, &data); err != nil { 120 | b.Fatal(err) 121 | } 122 | } 123 | }) 124 | }) 125 | } 126 | -------------------------------------------------------------------------------- /bench/testdata.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "_id": "67f3a66d303afda5a556f500", 4 | "index": 0, 5 | "guid": "59cfb2dc-69f0-4499-848d-63630464fc64", 6 | "isActive": true, 7 | "balance": "$3,344.61", 8 | "picture": "http://placehold.it/32x32", 9 | "age": 24, 10 | "eyeColor": "green", 11 | "name": "Scott Harding", 12 | "gender": "male", 13 | "company": "SHEPARD", 14 | "email": "scottharding@shepard.com", 15 | "phone": "+1 (906) 472-2005", 16 | "address": "360 Lafayette Walk, Bayview, West Virginia, 1550", 17 | "about": "Nostrud pariatur exercitation exercitation aliquip et incididunt consequat do id deserunt adipisicing pariatur. Labore culpa officia ad dolor esse excepteur culpa ullamco ea pariatur. Id culpa enim duis in aliquip tempor. Dolor cillum ullamco tempor velit mollit aliqua. Ea ipsum ipsum laboris irure.\r\n", 18 | "registered": "2024-03-02T08:30:52 -03:00", 19 | "latitude": 51.363906, 20 | "longitude": -84.394945, 21 | "tags": [ 22 | "ad", 23 | "fugiat", 24 | "velit", 25 | "adipisicing", 26 | "nostrud", 27 | "pariatur", 28 | "in" 29 | ], 30 | "friends": [ 31 | { 32 | "id": 0, 33 | "name": "Jody Bullock" 34 | }, 35 | { 36 | "id": 1, 37 | "name": "Fuller Phelps" 38 | }, 39 | { 40 | "id": 2, 41 | "name": "Janice Delaney" 42 | } 43 | ], 44 | "greeting": "Hello, Scott Harding! You have 2 unread messages.", 45 | "favoriteFruit": "banana" 46 | }, 47 | { 48 | "_id": "67f3a66d1818225c5bed4b37", 49 | "index": 1, 50 | "guid": "6df61c83-1ff4-49b5-b77f-cea18ce20ab8", 51 | "isActive": false, 52 | "balance": "$1,973.90", 53 | "picture": "http://placehold.it/32x32", 54 | "age": 38, 55 | "eyeColor": "brown", 56 | "name": "Dionne Dalton", 57 | "gender": "female", 58 | "company": "GOKO", 59 | "email": "dionnedalton@goko.com", 60 | "phone": "+1 (996) 447-3443", 61 | "address": "493 Chauncey Street, Belmont, Wyoming, 422", 62 | "about": "Dolore sunt cillum cupidatat magna proident deserunt ipsum. Reprehenderit ut quis ad voluptate id et. Cupidatat labore voluptate eu deserunt duis irure eiusmod ipsum ut laboris veniam est elit. Occaecat pariatur id consequat laborum. Excepteur nostrud ut tempor minim. Duis ullamco consequat quis Lorem ea consectetur quis minim aliquip sunt fugiat ad magna. Sint velit esse reprehenderit anim esse do eu do veniam culpa.\r\n", 63 | "registered": "2019-02-18T08:35:30 -03:00", 64 | "latitude": -56.307725, 65 | "longitude": -131.279098, 66 | "tags": [ 67 | "amet", 68 | "Lorem", 69 | "eiusmod", 70 | "ad", 71 | "et", 72 | "quis", 73 | "anim" 74 | ], 75 | "friends": [ 76 | { 77 | "id": 0, 78 | "name": "Gay Wolfe" 79 | }, 80 | { 81 | "id": 1, 82 | "name": "Owens Hodge" 83 | }, 84 | { 85 | "id": 2, 86 | "name": "Marshall Parsons" 87 | } 88 | ], 89 | "greeting": "Hello, Dionne Dalton! You have 3 unread messages.", 90 | "favoriteFruit": "apple" 91 | }, 92 | { 93 | "_id": "67f3a66ddf5c24d6e08d1449", 94 | "index": 2, 95 | "guid": "540746e4-d363-4ad0-81bc-c5d6065f81ee", 96 | "isActive": true, 97 | "balance": "$2,265.98", 98 | "picture": "http://placehold.it/32x32", 99 | "age": 20, 100 | "eyeColor": "brown", 101 | "name": "Fields Walker", 102 | "gender": "male", 103 | "company": "ISOLOGICS", 104 | "email": "fieldswalker@isologics.com", 105 | "phone": "+1 (884) 545-2848", 106 | "address": "729 Irving Street, Stevens, Wisconsin, 5212", 107 | "about": "Sint elit velit magna amet aute tempor consequat. Voluptate quis adipisicing ex est eu anim do. Proident id occaecat laborum culpa Lorem laborum ut minim laboris sit ipsum eiusmod. Ea laborum qui irure qui dolore ad voluptate magna ut nisi veniam.\r\n", 108 | "registered": "2018-06-17T08:28:28 -03:00", 109 | "latitude": 49.150317, 110 | "longitude": 174.688419, 111 | "tags": [ 112 | "incididunt", 113 | "esse", 114 | "adipisicing", 115 | "aute", 116 | "in", 117 | "nisi", 118 | "duis" 119 | ], 120 | "friends": [ 121 | { 122 | "id": 0, 123 | "name": "Melissa Mccray" 124 | }, 125 | { 126 | "id": 1, 127 | "name": "Ashley Graves" 128 | }, 129 | { 130 | "id": 2, 131 | "name": "Stacy Boone" 132 | } 133 | ], 134 | "greeting": "Hello, Fields Walker! You have 7 unread messages.", 135 | "favoriteFruit": "banana" 136 | }, 137 | { 138 | "_id": "67f3a66de49b8b8906e5d100", 139 | "index": 3, 140 | "guid": "865b1107-e39f-4238-a3a9-a2351700d384", 141 | "isActive": false, 142 | "balance": "$2,507.77", 143 | "picture": "http://placehold.it/32x32", 144 | "age": 25, 145 | "eyeColor": "brown", 146 | "name": "Debbie Duffy", 147 | "gender": "female", 148 | "company": "ZOLAR", 149 | "email": "debbieduffy@zolar.com", 150 | "phone": "+1 (941) 510-3091", 151 | "address": "419 Mill Road, Ilchester, Hawaii, 4231", 152 | "about": "Duis officia sint irure id reprehenderit reprehenderit eiusmod et consequat deserunt. Ex sit in exercitation ipsum exercitation anim veniam eiusmod ea fugiat esse. Aute incididunt culpa sint sunt ex dolore nulla non. Aliqua sint eiusmod irure consectetur ad. Qui elit dolore veniam non adipisicing deserunt pariatur in ipsum ut id non. Ad consequat nisi aute ut id proident duis aliqua duis dolor do esse laboris quis.\r\n", 153 | "registered": "2021-02-17T03:13:54 -03:00", 154 | "latitude": 8.456684, 155 | "longitude": -175.752763, 156 | "tags": [ 157 | "ipsum", 158 | "nostrud", 159 | "tempor", 160 | "Lorem", 161 | "nisi", 162 | "qui", 163 | "quis" 164 | ], 165 | "friends": [ 166 | { 167 | "id": 0, 168 | "name": "Jewell Schultz" 169 | }, 170 | { 171 | "id": 1, 172 | "name": "Sharp Randolph" 173 | }, 174 | { 175 | "id": 2, 176 | "name": "Hopkins Humphrey" 177 | } 178 | ], 179 | "greeting": "Hello, Debbie Duffy! You have 7 unread messages.", 180 | "favoriteFruit": "banana" 181 | }, 182 | { 183 | "_id": "67f3a66d5a5e53857d8e2245", 184 | "index": 4, 185 | "guid": "14c46cdf-6713-4454-8cee-6d9d2aa6b7b5", 186 | "isActive": false, 187 | "balance": "$1,380.68", 188 | "picture": "http://placehold.it/32x32", 189 | "age": 26, 190 | "eyeColor": "green", 191 | "name": "Robin Cantu", 192 | "gender": "female", 193 | "company": "RENOVIZE", 194 | "email": "robincantu@renovize.com", 195 | "phone": "+1 (990) 415-3882", 196 | "address": "953 Hanover Place, Barronett, New Jersey, 2175", 197 | "about": "Anim qui labore cupidatat voluptate qui. Ullamco aute do et ut velit. Magna ipsum nisi aliquip ut sit sint adipisicing exercitation eu laborum ex. Mollit dolore consectetur labore anim cillum dolore ut aliquip eiusmod amet nisi ut sunt commodo.\r\n", 198 | "registered": "2017-06-28T02:36:10 -03:00", 199 | "latitude": 15.582185, 200 | "longitude": 75.685368, 201 | "tags": [ 202 | "adipisicing", 203 | "dolor", 204 | "Lorem", 205 | "culpa", 206 | "consectetur", 207 | "ad", 208 | "quis" 209 | ], 210 | "friends": [ 211 | { 212 | "id": 0, 213 | "name": "Chasity Oconnor" 214 | }, 215 | { 216 | "id": 1, 217 | "name": "Roxanne Fox" 218 | }, 219 | { 220 | "id": 2, 221 | "name": "Maxwell Pratt" 222 | } 223 | ], 224 | "greeting": "Hello, Robin Cantu! You have 6 unread messages.", 225 | "favoriteFruit": "apple" 226 | }, 227 | { 228 | "_id": "67f3a66dee3b2244fe064208", 229 | "index": 5, 230 | "guid": "50fde902-3f3c-4f89-a1c9-76f9fbb6b566", 231 | "isActive": true, 232 | "balance": "$1,262.57", 233 | "picture": "http://placehold.it/32x32", 234 | "age": 34, 235 | "eyeColor": "green", 236 | "name": "Stevens Raymond", 237 | "gender": "male", 238 | "company": "COMVENE", 239 | "email": "stevensraymond@comvene.com", 240 | "phone": "+1 (889) 501-3169", 241 | "address": "830 Ferris Street, Hendersonville, Arizona, 4325", 242 | "about": "Sunt eiusmod aute et do veniam laborum est culpa sint esse et eu. Id elit quis nulla labore velit commodo proident culpa commodo qui commodo. Ipsum magna officia consectetur ea cupidatat sint fugiat magna minim occaecat sunt. Aliquip magna cillum occaecat do nostrud.\r\n", 243 | "registered": "2021-08-10T03:23:57 -03:00", 244 | "latitude": -14.476038, 245 | "longitude": -92.380946, 246 | "tags": [ 247 | "ipsum", 248 | "excepteur", 249 | "qui", 250 | "irure", 251 | "est", 252 | "laboris", 253 | "nostrud" 254 | ], 255 | "friends": [ 256 | { 257 | "id": 0, 258 | "name": "Terrie Keith" 259 | }, 260 | { 261 | "id": 1, 262 | "name": "Dorthy Watts" 263 | }, 264 | { 265 | "id": 2, 266 | "name": "Brianna Dotson" 267 | } 268 | ], 269 | "greeting": "Hello, Stevens Raymond! You have 3 unread messages.", 270 | "favoriteFruit": "banana" 271 | } 272 | ] 273 | -------------------------------------------------------------------------------- /cmd/schemagen/README.md: -------------------------------------------------------------------------------- 1 | # Schemagen 2 | 3 | Schemagen is a tool to generate effective implementation of Validateable 4 | interface for your types to reduce validation overhead to **ZERO**. 5 | 6 | Whether you choose to use code generation or runtime reflection traversal, validation will work either way. 7 | 8 | Using reflection is easier because it does not require any codegen setup, but it does introduce minor performance decrease. 9 | 10 | Unless performance is top-priority and validation is indeed a bottleneck (usually it's not), I'd recommend sticking with the reflection - it makes your codebase simpler to maintain. Though I've tried to make this tool as painless to use as go allows =) 11 | 12 | ## How to use 13 | 14 |
15 | Go 1.24+ (with tool directive) 16 | 17 | ```bash 18 | go get -tool github.com/metafates/schema/cmd/schemagen@main 19 | ``` 20 | 21 | This will add a tool directive to your `go.mod` file 22 | 23 | Then you can use it with `go:generate` directive (notice the `go tool` prefix) 24 | 25 | ```go 26 | //go:generate go tool schemagen -type Foo,Bar 27 | 28 | type Foo struct { 29 | A required.NonZero[string] 30 | B optional.Negative[int] 31 | } 32 | 33 | type Bar map[string]MyStruct 34 | ``` 35 | 36 |
37 | 38 |
39 | Go 1.23 and earlier 40 | 41 | See https://marcofranssen.nl/manage-go-tools-via-go-modules 42 | 43 | Or: 44 | 45 | ```bash 46 | go install github.com/metafates/schema/cmd/schemagen@main 47 | ``` 48 | 49 | Ensure that `schemagen` is in your `$PATH`: 50 | 51 | ```bash 52 | which schemagen # should output something if everything is ok 53 | ``` 54 | 55 | Then you can use it with `go:generate` directive 56 | 57 | ```go 58 | //go:generate schemagen -type Foo,Bar 59 | 60 | type Foo struct { 61 | A required.NonZero[string] 62 | B optional.Negative[int] 63 | } 64 | 65 | type Bar map[string]MyStruct 66 | ``` 67 | 68 |
69 | 70 | 71 | And call `go generate` as usual 72 | 73 | ```bash 74 | go generate ./... 75 | ``` 76 | 77 | You should see the following files generated: 78 | 79 | - `Foo_schema.go` 80 | - `Bar_schema.go` 81 | 82 | ## What does it do 83 | 84 | It generates `YOUR_TYPE_schema.gen` file with `Validate() error` method for each type specified. 85 | Therefore `validate.Validate(v any) error` will call this method instead of reflection-based field traversal. 86 | 87 | That's it! It will reduce validataion overhead to almost zero. 88 | -------------------------------------------------------------------------------- /cmd/schemagen/lock.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "go/types" 5 | 6 | "github.com/dave/jennifer/jen" 7 | "github.com/metafates/schema/internal/typeconv" 8 | ) 9 | 10 | func genLock(f *jen.File, named *types.Named) { 11 | f.Commentf("Ensure that [%s] type was not changed", named.Obj().Name()) 12 | f.Func().Id("_").Params().BlockFunc(func(g *jen.Group) { 13 | underlying := named.Underlying() 14 | 15 | converter := typeconv.NewTypeConverter() 16 | typeCode := converter.ConvertType(underlying) 17 | 18 | converter.AddImports(f) 19 | 20 | g.Type().Id("locked").Add(typeCode) 21 | 22 | varName := "v" 23 | 24 | g.Var().Id(varName).Id(named.Obj().Name()) 25 | 26 | g.Comment("Compiler error signifies that the type definition have changed.") 27 | g.Comment("Re-run the schemagen command to regenerate this file.") 28 | g.Id("_").Op("=").Id("locked").Params(jen.Id(varName)) 29 | }) 30 | } 31 | -------------------------------------------------------------------------------- /cmd/schemagen/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "cmp" 5 | "flag" 6 | "fmt" 7 | "go/types" 8 | "log" 9 | "os" 10 | "path/filepath" 11 | "slices" 12 | "strings" 13 | 14 | "github.com/dave/jennifer/jen" 15 | "golang.org/x/tools/go/packages" 16 | ) 17 | 18 | //nolint:gochecknoglobals // this is pretty common in go have flags as global variables 19 | var flagType = flag.String("type", "", "comma-separated list of type names; must be set") 20 | 21 | func Usage() { 22 | printf := func(format string, a ...any) { 23 | fmt.Fprintf(os.Stderr, format, a...) 24 | } 25 | 26 | printf("Schemagen is a tool to generate Go code for field-traversal validation\n") 27 | printf("Usage of %s:\n", os.Args[0]) 28 | printf("\tschemagen [flags] -type T [directory]\n") 29 | printf("For more information, see:\n") 30 | printf("\thttps://github.com/metafates/schema\n") 31 | printf("Flags:\n") 32 | 33 | flag.PrintDefaults() 34 | } 35 | 36 | func main() { 37 | log.SetFlags(0) 38 | log.SetPrefix("schemagen: ") 39 | 40 | flag.Usage = Usage 41 | flag.Parse() 42 | 43 | if len(*flagType) == 0 { 44 | flag.Usage() 45 | os.Exit(2) 46 | } 47 | 48 | args := flag.Args() 49 | if len(args) == 0 { 50 | // Default: process whole package in current directory. 51 | args = []string{"."} 52 | } 53 | 54 | g := generator{types: strings.Split(*flagType, ",")} 55 | 56 | g.genPackages(args...) 57 | } 58 | 59 | type generator struct { 60 | types []string 61 | } 62 | 63 | // For each type, generate code in the first package where the type is declared. 64 | // The order of packages is as follows: 65 | // package x 66 | // package x compiled for tests 67 | // package x_test 68 | // 69 | // Each package pass could result in a separate generated file. 70 | // These files must have the same package and test/not-test nature as the types 71 | // from which they were generated. 72 | // 73 | // Types will be excluded when generated, to avoid repetitions. 74 | func (g *generator) genPackages(patterns ...string) { 75 | pkgs := parsePackages(patterns...) 76 | 77 | slices.SortFunc(pkgs, func(a, b *packages.Package) int { 78 | aTest := strings.HasSuffix(a.Name, "_test") 79 | bTest := strings.HasSuffix(b.Name, "_test") 80 | 81 | if aTest != bTest { 82 | if !aTest { 83 | return -1 84 | } 85 | 86 | return 1 87 | } 88 | 89 | return cmp.Compare(len(a.GoFiles), len(b.GoFiles)) 90 | }) 91 | 92 | for _, pkg := range pkgs { 93 | g.genPackage(pkg) 94 | } 95 | } 96 | 97 | func hasTestFiles(pkg *packages.Package) bool { 98 | for _, f := range pkg.GoFiles { 99 | if strings.HasSuffix(f, "_test.go") { 100 | return true 101 | } 102 | } 103 | 104 | return false 105 | } 106 | 107 | func (g *generator) genPackage(pkg *packages.Package) { 108 | scope := pkg.Types.Scope() 109 | 110 | var foundTypes, remainingTypes []string 111 | 112 | // ensure that types exist 113 | for _, name := range g.types { 114 | if scope.Lookup(name) == nil { 115 | remainingTypes = append(remainingTypes, name) 116 | } else { 117 | foundTypes = append(foundTypes, name) 118 | } 119 | } 120 | 121 | if len(foundTypes) == 0 { 122 | // This package didn't have any of the relevant types, skip writing a file. 123 | return 124 | } 125 | 126 | if len(remainingTypes) > 0 { 127 | log.Fatal("cannot write to single file when matching types are found in multiple packages") 128 | } 129 | 130 | g.types = remainingTypes 131 | 132 | isTest := hasTestFiles(pkg) 133 | 134 | for _, name := range foundTypes { 135 | obj := scope.Lookup(name) 136 | 137 | f := jen.NewFilePathName(pkg.PkgPath, pkg.Name) 138 | f.HeaderComment("Code generated by schemagen; DO NOT EDIT.") 139 | 140 | named, ok := obj.Type().(*types.Named) 141 | if !ok { 142 | log.Fatalf("unexpected unnamed type: %T\n", obj.Type()) 143 | } 144 | 145 | genType(f, named) 146 | 147 | var basename string 148 | 149 | if isTest { 150 | basename = name + "_schema_test.go" 151 | } else { 152 | basename = name + "_schema.go" 153 | } 154 | 155 | outputPath := filepath.Join(pkg.Dir, basename) 156 | 157 | if err := f.Save(outputPath); err != nil { 158 | log.Fatalln(err) 159 | } 160 | } 161 | } 162 | 163 | func parsePackages(patterns ...string) []*packages.Package { 164 | cfg := packages.Config{ 165 | Mode: packages.LoadAllSyntax, 166 | Tests: true, 167 | } 168 | 169 | pkgs, err := packages.Load(&cfg, patterns...) 170 | if err != nil { 171 | log.Fatalln(err) 172 | } 173 | 174 | return pkgs 175 | } 176 | 177 | func genType(f *jen.File, named *types.Named) { 178 | genLock(f, named) 179 | genValidate(f, named) 180 | } 181 | -------------------------------------------------------------------------------- /cmd/schemagen/path.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "slices" 5 | "strings" 6 | ) 7 | 8 | type PathSegment struct { 9 | Name string 10 | Index bool 11 | Dynamic bool 12 | } 13 | 14 | type Path struct { 15 | Segments []PathSegment 16 | } 17 | 18 | func (p Path) Join(segment PathSegment) Path { 19 | return Path{ 20 | Segments: append(slices.Clone(p.Segments), segment), 21 | } 22 | } 23 | 24 | func (p Path) String() string { 25 | if len(p.Segments) == 0 { 26 | return "" 27 | } 28 | 29 | root := p.Segments[0].Name 30 | 31 | var rest strings.Builder 32 | 33 | rest.Grow(50) 34 | 35 | for _, s := range p.Segments[1:] { 36 | if s.Index { 37 | rest.WriteString("[" + s.Name + "]") 38 | } else { 39 | rest.WriteString("." + s.Name) 40 | } 41 | } 42 | 43 | return root + rest.String() 44 | } 45 | 46 | // printf returns f-template (go style) and its arguments for this path. 47 | func (p Path) printf() (string, []string) { 48 | var formatBuilder strings.Builder 49 | 50 | var args []string 51 | 52 | for _, s := range p.Segments[1:] { 53 | var prefix, suffix string 54 | 55 | if s.Index { 56 | prefix = "[" 57 | suffix = "]" 58 | } else { 59 | prefix = "." 60 | } 61 | 62 | formatBuilder.WriteString(prefix) 63 | 64 | if s.Dynamic { 65 | formatBuilder.WriteString("%v") 66 | 67 | args = append(args, s.Name) 68 | } else { 69 | formatBuilder.WriteString(s.Name) 70 | } 71 | 72 | formatBuilder.WriteString(suffix) 73 | } 74 | 75 | return formatBuilder.String(), args 76 | } 77 | -------------------------------------------------------------------------------- /cmd/schemagen/validate.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "go/types" 6 | 7 | "github.com/dave/jennifer/jen" 8 | ) 9 | 10 | const validatePkg = "github.com/metafates/schema/validate" 11 | 12 | func genValidate(f *jen.File, named *types.Named) { 13 | const receiver = "x" 14 | 15 | receiverPtr := "*" 16 | 17 | switch named.Underlying().(type) { 18 | case *types.Slice, *types.Map: 19 | receiverPtr = "" 20 | } 21 | 22 | f.Comment("TypeValidate implements the [validate.TypeValidateable] interface.") 23 | f. 24 | Func(). 25 | Params(jen.Id(receiver).Op(receiverPtr).Id(named.Obj().Name())). 26 | Id("TypeValidate"). 27 | Params(). 28 | Error(). 29 | BlockFunc(func(g *jen.Group) { 30 | gen := validateGenerator{counter: make(map[string]int)} 31 | 32 | gen.gen( 33 | g, 34 | Path{}.Join(PathSegment{Name: receiver}), 35 | named.Underlying(), 36 | false, 37 | true, 38 | ) 39 | 40 | g.Return().Nil() 41 | }) 42 | } 43 | 44 | type validateGenerator struct { 45 | counter map[string]int 46 | } 47 | 48 | func (vg *validateGenerator) gen( 49 | g *jen.Group, 50 | path Path, 51 | t types.Type, 52 | isPtr, addressable bool, 53 | ) bool { 54 | switch t := t.(type) { 55 | default: 56 | vg.genBot(g, path, isPtr, addressable) 57 | 58 | return true 59 | 60 | case *types.Pointer: 61 | return vg.genPointer(g, path, t, addressable) 62 | 63 | case *types.Slice: 64 | return vg.genSlice(g, path, t, isPtr, addressable) 65 | 66 | case *types.Map: 67 | return vg.genMap(g, path, t, isPtr) 68 | 69 | case *types.Struct: 70 | return vg.genStruct(g, path, t, isPtr, addressable) 71 | 72 | case *types.Basic: 73 | return false 74 | } 75 | } 76 | 77 | func (vg *validateGenerator) genPointer( 78 | g *jen.Group, 79 | path Path, 80 | s *types.Pointer, 81 | addressable bool, 82 | ) bool { 83 | var generated bool 84 | 85 | ifBody := jen.BlockFunc(func(g *jen.Group) { 86 | generated = vg.gen(g, path, s.Elem(), true, addressable) 87 | }) 88 | 89 | if generated { 90 | g.If(jen.Id(path.String()).Op("!=").Nil()).Block(ifBody) 91 | } 92 | 93 | return generated 94 | } 95 | 96 | func (vg *validateGenerator) genSlice( 97 | g *jen.Group, 98 | path Path, 99 | s *types.Slice, 100 | isPtr, addressable bool, 101 | ) bool { 102 | i := vg.unique("i") 103 | 104 | var generatedAny bool 105 | 106 | loopBody := jen.BlockFunc(func(g *jen.Group) { 107 | itemPath := path.Join(PathSegment{Name: i, Dynamic: true, Index: true}) 108 | 109 | if vg.gen(g, itemPath, s.Elem(), isPtr, addressable) { 110 | generatedAny = true 111 | } 112 | }) 113 | 114 | if generatedAny { 115 | g.For(jen.Id(i).Op(":=").Range().Id(path.String())).Block(loopBody) 116 | } 117 | 118 | return generatedAny 119 | } 120 | 121 | func (vg *validateGenerator) genMap(g *jen.Group, path Path, s *types.Map, isPtr bool) bool { 122 | k := vg.unique("k") 123 | 124 | var generatedAny bool 125 | 126 | loopBody := jen.BlockFunc(func(g *jen.Group) { 127 | valuePath := path.Join(PathSegment{Name: k, Dynamic: true, Index: true}) 128 | 129 | if vg.gen(g, valuePath, s.Elem(), isPtr, false) { 130 | generatedAny = true 131 | } 132 | }) 133 | 134 | if generatedAny { 135 | g.For(jen.Id(k).Op(":=").Range().Id(path.String())).Block(loopBody) 136 | } 137 | 138 | return generatedAny 139 | } 140 | 141 | func (vg *validateGenerator) genStruct( 142 | g *jen.Group, 143 | path Path, 144 | s *types.Struct, 145 | isPtr, addressable bool, 146 | ) bool { 147 | var generatedAny bool 148 | 149 | for field := range s.Fields() { 150 | if !field.Exported() { 151 | continue 152 | } 153 | 154 | fieldPath := path.Join(PathSegment{Name: field.Name()}) 155 | 156 | if vg.gen(g, fieldPath, field.Type(), isPtr, addressable) { 157 | generatedAny = true 158 | } 159 | } 160 | 161 | return generatedAny 162 | } 163 | 164 | func (vg *validateGenerator) genBot(g *jen.Group, path Path, isPtr, addressable bool) { 165 | errName := vg.unique("err") 166 | 167 | value := path.String() 168 | 169 | switch { 170 | case isPtr: 171 | g.Id(errName).Op(":=").Qual(validatePkg, "Validate").Call(jen.Id(value)) 172 | 173 | case addressable: 174 | g.Id(errName).Op(":=").Qual(validatePkg, "Validate").Call(jen.Op("&").Id(value)) 175 | 176 | default: 177 | valueName := vg.unique("v") 178 | 179 | g.Id(valueName).Op(":=").Id(value) 180 | 181 | g.Id(errName).Op(":=").Qual(validatePkg, "Validate").Call(jen.Op("&").Id(valueName)) 182 | 183 | g.Id(path.String()).Op("=").Id(valueName) 184 | } 185 | 186 | g.If(jen.Id(errName).Op("!=").Nil()).Block( 187 | jen.Return(). 188 | Qual(validatePkg, "ValidationError"). 189 | Values(jen.Dict{ 190 | jen.Id("Inner"): jen.Id(errName), 191 | }). 192 | Dot("WithPath"). 193 | Call(jen.Qual("fmt", "Sprintf").CallFunc(func(g *jen.Group) { 194 | format, args := path.printf() 195 | 196 | g.Lit(format) 197 | 198 | for _, a := range args { 199 | g.Id(a) 200 | } 201 | })), 202 | ) 203 | } 204 | 205 | func (vg *validateGenerator) unique(id string) string { 206 | // we could use a single counter for all ids 207 | // but using counter for each id generates better looking code 208 | count, ok := vg.counter[id] 209 | if !ok { 210 | vg.counter[id] = count 211 | } 212 | 213 | vg.counter[id]++ 214 | 215 | return fmt.Sprintf("%s%d", id, count) 216 | } 217 | -------------------------------------------------------------------------------- /constraint/constraint.go: -------------------------------------------------------------------------------- 1 | // Package constraint provides common type constraints used in validators. 2 | package constraint 3 | 4 | import "time" 5 | 6 | type Float interface { 7 | ~float32 | ~float64 8 | } 9 | 10 | type Integer interface { 11 | Signed | Unsigned 12 | } 13 | 14 | type Signed interface { 15 | ~int | ~int8 | ~int16 | ~int32 | ~int64 16 | } 17 | 18 | type Unsigned interface { 19 | ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr 20 | } 21 | 22 | type Real interface{ Float | Integer } 23 | 24 | // Text constraints types that can be converted to string. 25 | type Text interface{ ~string | ~[]rune | ~[]byte } 26 | 27 | type Comparable[T any] interface{ Compare(other T) int } 28 | 29 | type Time = Comparable[time.Time] 30 | -------------------------------------------------------------------------------- /encoding/json/json.go: -------------------------------------------------------------------------------- 1 | // Package schemajson wraps json decoding functions and calls validation after unmarshalling. 2 | package schemajson 3 | 4 | import ( 5 | "encoding/json" 6 | "io" 7 | 8 | "github.com/metafates/schema/validate" 9 | ) 10 | 11 | // Decoder wraps [json.Decoder] with validation step after decoding. 12 | // 13 | // See [json.Decoder] documentation. 14 | type Decoder struct { 15 | *json.Decoder 16 | } 17 | 18 | // NewDecoder returns a new decoder that reads from r. 19 | // 20 | // See [json.NewDecoder] documentation. 21 | func NewDecoder(r io.Reader) *Decoder { 22 | return &Decoder{json.NewDecoder(r)} 23 | } 24 | 25 | // Decode wraps [json.Decoder.Decode] and calls [validate.Validate] afterwards. 26 | // 27 | // See also [Unmarshal]. 28 | func (dec *Decoder) Decode(v any) error { 29 | if err := dec.Decoder.Decode(v); err != nil { 30 | return err 31 | } 32 | 33 | if err := validate.Validate(v); err != nil { 34 | return err 35 | } 36 | 37 | return nil 38 | } 39 | 40 | // Unmarshal wraps [json.Unmarshal] and calls [validate.Validate] afterwards. 41 | // 42 | // See also [Decoder.Decode]. 43 | func Unmarshal(data []byte, v any) error { 44 | if err := json.Unmarshal(data, v); err != nil { 45 | return err 46 | } 47 | 48 | if err := validate.Validate(v); err != nil { 49 | return err 50 | } 51 | 52 | return nil 53 | } 54 | -------------------------------------------------------------------------------- /encoding/json/json_test.go: -------------------------------------------------------------------------------- 1 | package schemajson 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/metafates/schema/internal/testutil" 8 | "github.com/metafates/schema/optional" 9 | ) 10 | 11 | func TestJSON(t *testing.T) { 12 | type Mock struct { 13 | Foo string `json:"foo"` 14 | Bar optional.Positive[int] `json:"bar"` 15 | } 16 | 17 | for _, tc := range []struct { 18 | name string 19 | json string 20 | wantErr bool 21 | }{ 22 | { 23 | name: "valid json", 24 | json: `{"foo": "lorem ipsum", "bar": 249}`, 25 | }, 26 | { 27 | name: "invalid json", 28 | json: `{"foo": lorem ipsum, ||||||| bar: 249}`, 29 | wantErr: true, 30 | }, 31 | { 32 | name: "validation error", 33 | json: `{"foo": "lorem ipsum", "bar": -2}`, 34 | wantErr: true, 35 | }, 36 | } { 37 | t.Run(tc.name, func(t *testing.T) { 38 | t.Run("unmarshal", func(t *testing.T) { 39 | var mock Mock 40 | err := Unmarshal([]byte(tc.json), &mock) 41 | 42 | if tc.wantErr { 43 | testutil.Error(t, err) 44 | } else { 45 | testutil.NoError(t, err) 46 | } 47 | }) 48 | 49 | t.Run("decoder", func(t *testing.T) { 50 | var mock Mock 51 | 52 | err := NewDecoder(strings.NewReader(tc.json)).Decode(&mock) 53 | 54 | if tc.wantErr { 55 | testutil.Error(t, err) 56 | } else { 57 | testutil.NoError(t, err) 58 | } 59 | }) 60 | }) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /examples/codegen/User_schema.go: -------------------------------------------------------------------------------- 1 | // Code generated by schemagen; DO NOT EDIT. 2 | 3 | package main 4 | 5 | import ( 6 | "fmt" 7 | "github.com/metafates/schema/optional" 8 | "github.com/metafates/schema/required" 9 | "github.com/metafates/schema/validate" 10 | "github.com/metafates/schema/validate/charset" 11 | "time" 12 | ) 13 | 14 | // Ensure that [User] type was not changed 15 | func _() { 16 | type locked struct { 17 | ID required.Custom[string, validate.UUID[string]] `json:"id"` 18 | Name required.Custom[string, validate.Charset[string, charset.Print]] `json:"name"` 19 | Birth optional.Custom[time.Time, validate.InPast[time.Time]] `json:"birth"` 20 | Meta struct { 21 | Preferences optional.Custom[[]string, validate.UniqueSlice[string]] `json:"preferences"` 22 | Admin bool `json:"admin"` 23 | } `json:"meta"` 24 | Friends []UserFriend `json:"friends"` 25 | Addresses []struct { 26 | Tag optional.Custom[string, validate.Charset[string, charset.Print]] `json:"tag"` 27 | Latitude required.Custom[float64, validate.Latitude[float64]] `json:"latitude"` 28 | Longitude required.Custom[float64, validate.Longitude[float64]] `json:"longitude"` 29 | } `json:"addresses"` 30 | } 31 | var v User 32 | // Compiler error signifies that the type definition have changed. 33 | // Re-run the schemagen command to regenerate this file. 34 | _ = locked(v) 35 | } 36 | 37 | // TypeValidate implements the [validate.TypeValidateable] interface. 38 | func (x *User) TypeValidate() error { 39 | err0 := validate.Validate(&x.ID) 40 | if err0 != nil { 41 | return validate.ValidationError{Inner: err0}.WithPath(fmt.Sprintf(".ID")) 42 | } 43 | err1 := validate.Validate(&x.Name) 44 | if err1 != nil { 45 | return validate.ValidationError{Inner: err1}.WithPath(fmt.Sprintf(".Name")) 46 | } 47 | err2 := validate.Validate(&x.Birth) 48 | if err2 != nil { 49 | return validate.ValidationError{Inner: err2}.WithPath(fmt.Sprintf(".Birth")) 50 | } 51 | err3 := validate.Validate(&x.Meta.Preferences) 52 | if err3 != nil { 53 | return validate.ValidationError{Inner: err3}.WithPath(fmt.Sprintf(".Meta.Preferences")) 54 | } 55 | for i0 := range x.Friends { 56 | { 57 | err4 := validate.Validate(&x.Friends[i0]) 58 | if err4 != nil { 59 | return validate.ValidationError{Inner: err4}.WithPath(fmt.Sprintf(".Friends[%v]", i0)) 60 | } 61 | } 62 | } 63 | for i1 := range x.Addresses { 64 | { 65 | err5 := validate.Validate(&x.Addresses[i1].Tag) 66 | if err5 != nil { 67 | return validate.ValidationError{Inner: err5}.WithPath(fmt.Sprintf(".Addresses[%v].Tag", i1)) 68 | } 69 | err6 := validate.Validate(&x.Addresses[i1].Latitude) 70 | if err6 != nil { 71 | return validate.ValidationError{Inner: err6}.WithPath(fmt.Sprintf(".Addresses[%v].Latitude", i1)) 72 | } 73 | err7 := validate.Validate(&x.Addresses[i1].Longitude) 74 | if err7 != nil { 75 | return validate.ValidationError{Inner: err7}.WithPath(fmt.Sprintf(".Addresses[%v].Longitude", i1)) 76 | } 77 | } 78 | } 79 | return nil 80 | } 81 | -------------------------------------------------------------------------------- /examples/codegen/invalid_data.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "32541419-1294-47e4-b070-833db7684866", 3 | "name": "J\nohn", 4 | "birth": "2006-01-02T15:04:05Z", 5 | "meta": { 6 | "preferences": ["dark_theme", "large_font"], 7 | "admin": false 8 | }, 9 | "friends": [ 10 | { 11 | "id": "9f840137-af40-4a5e-b544-dfbdb433cc37", 12 | "name": "Jane" 13 | } 14 | ], 15 | "addresses": [ 16 | { 17 | "tag": "home", 18 | "latitude": 12.1231, 19 | "longitude": 30.99 20 | }, 21 | { 22 | "latitude": 59.1231, 23 | "longitude": 30.99 24 | } 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /examples/codegen/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | _ "embed" 5 | "fmt" 6 | "log" 7 | "time" 8 | 9 | schemajson "github.com/metafates/schema/encoding/json" 10 | "github.com/metafates/schema/optional" 11 | "github.com/metafates/schema/required" 12 | "github.com/metafates/schema/validate/charset" 13 | ) 14 | 15 | //go:generate schemagen -type User 16 | 17 | type User struct { 18 | ID required.UUID[string] `json:"id"` 19 | Name required.Charset[string, charset.Print] `json:"name"` 20 | Birth optional.InPast[time.Time] `json:"birth"` 21 | 22 | Meta struct { 23 | Preferences optional.UniqueSlice[string] `json:"preferences"` 24 | Admin bool `json:"admin"` 25 | } `json:"meta"` 26 | 27 | Friends []UserFriend `json:"friends"` 28 | 29 | Addresses []struct { 30 | Tag optional.Charset[string, charset.Print] `json:"tag"` 31 | Latitude required.Latitude[float64] `json:"latitude"` 32 | Longitude required.Longitude[float64] `json:"longitude"` 33 | } `json:"addresses"` 34 | } 35 | 36 | type UserFriend struct { 37 | ID required.UUID[string] `json:"id"` 38 | Name required.Charset[string, charset.Print] `json:"name"` 39 | } 40 | 41 | var ( 42 | //go:embed valid_data.json 43 | validData string 44 | 45 | //go:embed invalid_data.json 46 | invalidName string 47 | ) 48 | 49 | func main() { 50 | { 51 | var user User 52 | 53 | if err := schemajson.Unmarshal([]byte(validData), &user); err != nil { 54 | log.Fatalln(err) 55 | } 56 | 57 | fmt.Println(user.ID.Get()) 58 | // 32541419-1294-47e4-b070-833db7684866 59 | } 60 | { 61 | var user User 62 | 63 | fmt.Println(schemajson.Unmarshal([]byte(invalidName), &user)) 64 | // validate: .Name: string contains unprintable character 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /examples/codegen/valid_data.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "32541419-1294-47e4-b070-833db7684866", 3 | "name": "John", 4 | "birth": "2006-01-02T15:04:05Z", 5 | "meta": { 6 | "preferences": ["dark_theme", "large_font"], 7 | "admin": false 8 | }, 9 | "friends": [ 10 | { 11 | "id": "9f840137-af40-4a5e-b544-dfbdb433cc37", 12 | "name": "Jane" 13 | } 14 | ], 15 | "addresses": [ 16 | { 17 | "tag": "home", 18 | "latitude": 12.1231, 19 | "longitude": 30.99 20 | }, 21 | { 22 | "latitude": 59.1231, 23 | "longitude": 30.99 24 | } 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /examples/parse-grpc/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | 7 | "github.com/metafates/schema/examples/parse-grpc/pb" 8 | "github.com/metafates/schema/optional" 9 | "github.com/metafates/schema/parse" 10 | "github.com/metafates/schema/required" 11 | ) 12 | 13 | type AddressBook struct { 14 | People []Person 15 | } 16 | 17 | type Person struct { 18 | Name required.NonZero[string] 19 | Id required.Positive0[int32] 20 | Email optional.Email[string] 21 | Phones []PhoneNumber 22 | } 23 | 24 | type PhoneType int 25 | 26 | const ( 27 | PhoneTypeMobile = iota 28 | PhoneTypeHome 29 | PhoneTypeWork 30 | ) 31 | 32 | type PhoneNumber struct { 33 | Type optional.Any[PhoneType] 34 | Number required.NonZero[string] 35 | } 36 | 37 | func main() { 38 | options := []parse.Option{ 39 | parse.WithDisallowUnknownFields(), 40 | } 41 | 42 | // let's parse valid address book from grpc 43 | { 44 | var book AddressBook 45 | 46 | err := parse.Parse(pb.AddressBook{ 47 | People: []*pb.Person{ 48 | { 49 | Name: "Example Name", 50 | Id: 12345, 51 | Email: "name@example.com", 52 | Phones: []*pb.Person_PhoneNumber{ 53 | { 54 | Number: "123-456-7890", 55 | Type: pb.Person_HOME, 56 | }, 57 | { 58 | Number: "222-222-2222", 59 | Type: pb.Person_MOBILE, 60 | }, 61 | { 62 | Number: "111-111-1111", 63 | Type: pb.Person_WORK, 64 | }, 65 | }, 66 | }, 67 | }, 68 | }, &book, options...) 69 | if err != nil { 70 | log.Fatalln(err) 71 | } 72 | 73 | fmt.Printf("book.People: %v\n", len(book.People)) 74 | // 1 75 | 76 | fmt.Printf("book.People[0].Name: %v\n", book.People[0].Name.Get()) 77 | // Example Name 78 | 79 | fmt.Printf("book.People[0].Phones: %v\n", len(book.People[0].Phones)) 80 | // 3 81 | 82 | fmt.Printf( 83 | "pb.Person_MOBILE == PhoneTypeMobile = %v\n", 84 | book.People[0].Phones[1].Type.Must() == PhoneTypeMobile, 85 | ) 86 | // pb.Person_MOBILE == PhoneTypeMobile = true 87 | } 88 | 89 | // now let's try to trigger error by violating the schema 90 | { 91 | var book AddressBook 92 | 93 | err := parse.Parse(pb.AddressBook{ 94 | People: []*pb.Person{ 95 | { 96 | Name: "Example Name", 97 | Id: 12345, 98 | Email: "not a valid email", 99 | Phones: []*pb.Person_PhoneNumber{ 100 | { 101 | Number: "123-456-7890", 102 | Type: pb.Person_HOME, 103 | }, 104 | { 105 | Type: pb.Person_MOBILE, 106 | }, 107 | { 108 | Number: "111-111-1111", 109 | Type: pb.Person_WORK, 110 | }, 111 | }, 112 | }, 113 | }, 114 | }, &book, options...) 115 | 116 | fmt.Println(err) // [0].Email: mail: no angle-addr 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /examples/parse-grpc/pb/example.pb.go: -------------------------------------------------------------------------------- 1 | // Code generated by protoc-gen-go. DO NOT EDIT. 2 | // versions: 3 | // protoc-gen-go v1.36.6 4 | // protoc v5.29.3 5 | // source: example.proto 6 | 7 | package pb 8 | 9 | import ( 10 | protoreflect "google.golang.org/protobuf/reflect/protoreflect" 11 | protoimpl "google.golang.org/protobuf/runtime/protoimpl" 12 | reflect "reflect" 13 | sync "sync" 14 | unsafe "unsafe" 15 | ) 16 | 17 | const ( 18 | // Verify that this generated code is sufficiently up-to-date. 19 | _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) 20 | // Verify that runtime/protoimpl is sufficiently up-to-date. 21 | _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) 22 | ) 23 | 24 | type Person_PhoneType int32 25 | 26 | const ( 27 | Person_MOBILE Person_PhoneType = 0 28 | Person_HOME Person_PhoneType = 1 29 | Person_WORK Person_PhoneType = 2 30 | ) 31 | 32 | // Enum value maps for Person_PhoneType. 33 | var ( 34 | Person_PhoneType_name = map[int32]string{ 35 | 0: "MOBILE", 36 | 1: "HOME", 37 | 2: "WORK", 38 | } 39 | Person_PhoneType_value = map[string]int32{ 40 | "MOBILE": 0, 41 | "HOME": 1, 42 | "WORK": 2, 43 | } 44 | ) 45 | 46 | func (x Person_PhoneType) Enum() *Person_PhoneType { 47 | p := new(Person_PhoneType) 48 | *p = x 49 | return p 50 | } 51 | 52 | func (x Person_PhoneType) String() string { 53 | return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) 54 | } 55 | 56 | func (Person_PhoneType) Descriptor() protoreflect.EnumDescriptor { 57 | return file_example_proto_enumTypes[0].Descriptor() 58 | } 59 | 60 | func (Person_PhoneType) Type() protoreflect.EnumType { 61 | return &file_example_proto_enumTypes[0] 62 | } 63 | 64 | func (x Person_PhoneType) Number() protoreflect.EnumNumber { 65 | return protoreflect.EnumNumber(x) 66 | } 67 | 68 | // Deprecated: Use Person_PhoneType.Descriptor instead. 69 | func (Person_PhoneType) EnumDescriptor() ([]byte, []int) { 70 | return file_example_proto_rawDescGZIP(), []int{0, 0} 71 | } 72 | 73 | type Person struct { 74 | state protoimpl.MessageState `protogen:"open.v1"` 75 | Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` 76 | Id int32 `protobuf:"varint,2,opt,name=id,proto3" json:"id,omitempty"` // Unique ID number for this person. 77 | Email string `protobuf:"bytes,3,opt,name=email,proto3" json:"email,omitempty"` 78 | Phones []*Person_PhoneNumber `protobuf:"bytes,4,rep,name=phones,proto3" json:"phones,omitempty"` 79 | unknownFields protoimpl.UnknownFields 80 | sizeCache protoimpl.SizeCache 81 | } 82 | 83 | func (x *Person) Reset() { 84 | *x = Person{} 85 | mi := &file_example_proto_msgTypes[0] 86 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 87 | ms.StoreMessageInfo(mi) 88 | } 89 | 90 | func (x *Person) String() string { 91 | return protoimpl.X.MessageStringOf(x) 92 | } 93 | 94 | func (*Person) ProtoMessage() {} 95 | 96 | func (x *Person) ProtoReflect() protoreflect.Message { 97 | mi := &file_example_proto_msgTypes[0] 98 | if x != nil { 99 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 100 | if ms.LoadMessageInfo() == nil { 101 | ms.StoreMessageInfo(mi) 102 | } 103 | return ms 104 | } 105 | return mi.MessageOf(x) 106 | } 107 | 108 | // Deprecated: Use Person.ProtoReflect.Descriptor instead. 109 | func (*Person) Descriptor() ([]byte, []int) { 110 | return file_example_proto_rawDescGZIP(), []int{0} 111 | } 112 | 113 | func (x *Person) GetName() string { 114 | if x != nil { 115 | return x.Name 116 | } 117 | return "" 118 | } 119 | 120 | func (x *Person) GetId() int32 { 121 | if x != nil { 122 | return x.Id 123 | } 124 | return 0 125 | } 126 | 127 | func (x *Person) GetEmail() string { 128 | if x != nil { 129 | return x.Email 130 | } 131 | return "" 132 | } 133 | 134 | func (x *Person) GetPhones() []*Person_PhoneNumber { 135 | if x != nil { 136 | return x.Phones 137 | } 138 | return nil 139 | } 140 | 141 | // Our address book file is just one of these. 142 | type AddressBook struct { 143 | state protoimpl.MessageState `protogen:"open.v1"` 144 | People []*Person `protobuf:"bytes,1,rep,name=people,proto3" json:"people,omitempty"` 145 | unknownFields protoimpl.UnknownFields 146 | sizeCache protoimpl.SizeCache 147 | } 148 | 149 | func (x *AddressBook) Reset() { 150 | *x = AddressBook{} 151 | mi := &file_example_proto_msgTypes[1] 152 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 153 | ms.StoreMessageInfo(mi) 154 | } 155 | 156 | func (x *AddressBook) String() string { 157 | return protoimpl.X.MessageStringOf(x) 158 | } 159 | 160 | func (*AddressBook) ProtoMessage() {} 161 | 162 | func (x *AddressBook) ProtoReflect() protoreflect.Message { 163 | mi := &file_example_proto_msgTypes[1] 164 | if x != nil { 165 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 166 | if ms.LoadMessageInfo() == nil { 167 | ms.StoreMessageInfo(mi) 168 | } 169 | return ms 170 | } 171 | return mi.MessageOf(x) 172 | } 173 | 174 | // Deprecated: Use AddressBook.ProtoReflect.Descriptor instead. 175 | func (*AddressBook) Descriptor() ([]byte, []int) { 176 | return file_example_proto_rawDescGZIP(), []int{1} 177 | } 178 | 179 | func (x *AddressBook) GetPeople() []*Person { 180 | if x != nil { 181 | return x.People 182 | } 183 | return nil 184 | } 185 | 186 | type Person_PhoneNumber struct { 187 | state protoimpl.MessageState `protogen:"open.v1"` 188 | Number string `protobuf:"bytes,1,opt,name=number,proto3" json:"number,omitempty"` 189 | Type Person_PhoneType `protobuf:"varint,2,opt,name=type,proto3,enum=pb.Person_PhoneType" json:"type,omitempty"` 190 | unknownFields protoimpl.UnknownFields 191 | sizeCache protoimpl.SizeCache 192 | } 193 | 194 | func (x *Person_PhoneNumber) Reset() { 195 | *x = Person_PhoneNumber{} 196 | mi := &file_example_proto_msgTypes[2] 197 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 198 | ms.StoreMessageInfo(mi) 199 | } 200 | 201 | func (x *Person_PhoneNumber) String() string { 202 | return protoimpl.X.MessageStringOf(x) 203 | } 204 | 205 | func (*Person_PhoneNumber) ProtoMessage() {} 206 | 207 | func (x *Person_PhoneNumber) ProtoReflect() protoreflect.Message { 208 | mi := &file_example_proto_msgTypes[2] 209 | if x != nil { 210 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 211 | if ms.LoadMessageInfo() == nil { 212 | ms.StoreMessageInfo(mi) 213 | } 214 | return ms 215 | } 216 | return mi.MessageOf(x) 217 | } 218 | 219 | // Deprecated: Use Person_PhoneNumber.ProtoReflect.Descriptor instead. 220 | func (*Person_PhoneNumber) Descriptor() ([]byte, []int) { 221 | return file_example_proto_rawDescGZIP(), []int{0, 0} 222 | } 223 | 224 | func (x *Person_PhoneNumber) GetNumber() string { 225 | if x != nil { 226 | return x.Number 227 | } 228 | return "" 229 | } 230 | 231 | func (x *Person_PhoneNumber) GetType() Person_PhoneType { 232 | if x != nil { 233 | return x.Type 234 | } 235 | return Person_MOBILE 236 | } 237 | 238 | var File_example_proto protoreflect.FileDescriptor 239 | 240 | const file_example_proto_rawDesc = "" + 241 | "\n" + 242 | "\rexample.proto\x12\x02pb\"\xf0\x01\n" + 243 | "\x06Person\x12\x12\n" + 244 | "\x04name\x18\x01 \x01(\tR\x04name\x12\x0e\n" + 245 | "\x02id\x18\x02 \x01(\x05R\x02id\x12\x14\n" + 246 | "\x05email\x18\x03 \x01(\tR\x05email\x12.\n" + 247 | "\x06phones\x18\x04 \x03(\v2\x16.pb.Person.PhoneNumberR\x06phones\x1aO\n" + 248 | "\vPhoneNumber\x12\x16\n" + 249 | "\x06number\x18\x01 \x01(\tR\x06number\x12(\n" + 250 | "\x04type\x18\x02 \x01(\x0e2\x14.pb.Person.PhoneTypeR\x04type\"+\n" + 251 | "\tPhoneType\x12\n" + 252 | "\n" + 253 | "\x06MOBILE\x10\x00\x12\b\n" + 254 | "\x04HOME\x10\x01\x12\b\n" + 255 | "\x04WORK\x10\x02\"1\n" + 256 | "\vAddressBook\x12\"\n" + 257 | "\x06people\x18\x01 \x03(\v2\n" + 258 | ".pb.PersonR\x06peopleB4Z2github.com/metafates/schame/examples/parse-grpc/pbb\x06proto3" 259 | 260 | var ( 261 | file_example_proto_rawDescOnce sync.Once 262 | file_example_proto_rawDescData []byte 263 | ) 264 | 265 | func file_example_proto_rawDescGZIP() []byte { 266 | file_example_proto_rawDescOnce.Do(func() { 267 | file_example_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_example_proto_rawDesc), len(file_example_proto_rawDesc))) 268 | }) 269 | return file_example_proto_rawDescData 270 | } 271 | 272 | var file_example_proto_enumTypes = make([]protoimpl.EnumInfo, 1) 273 | var file_example_proto_msgTypes = make([]protoimpl.MessageInfo, 3) 274 | var file_example_proto_goTypes = []any{ 275 | (Person_PhoneType)(0), // 0: pb.Person.PhoneType 276 | (*Person)(nil), // 1: pb.Person 277 | (*AddressBook)(nil), // 2: pb.AddressBook 278 | (*Person_PhoneNumber)(nil), // 3: pb.Person.PhoneNumber 279 | } 280 | var file_example_proto_depIdxs = []int32{ 281 | 3, // 0: pb.Person.phones:type_name -> pb.Person.PhoneNumber 282 | 1, // 1: pb.AddressBook.people:type_name -> pb.Person 283 | 0, // 2: pb.Person.PhoneNumber.type:type_name -> pb.Person.PhoneType 284 | 3, // [3:3] is the sub-list for method output_type 285 | 3, // [3:3] is the sub-list for method input_type 286 | 3, // [3:3] is the sub-list for extension type_name 287 | 3, // [3:3] is the sub-list for extension extendee 288 | 0, // [0:3] is the sub-list for field type_name 289 | } 290 | 291 | func init() { file_example_proto_init() } 292 | func file_example_proto_init() { 293 | if File_example_proto != nil { 294 | return 295 | } 296 | type x struct{} 297 | out := protoimpl.TypeBuilder{ 298 | File: protoimpl.DescBuilder{ 299 | GoPackagePath: reflect.TypeOf(x{}).PkgPath(), 300 | RawDescriptor: unsafe.Slice(unsafe.StringData(file_example_proto_rawDesc), len(file_example_proto_rawDesc)), 301 | NumEnums: 1, 302 | NumMessages: 3, 303 | NumExtensions: 0, 304 | NumServices: 0, 305 | }, 306 | GoTypes: file_example_proto_goTypes, 307 | DependencyIndexes: file_example_proto_depIdxs, 308 | EnumInfos: file_example_proto_enumTypes, 309 | MessageInfos: file_example_proto_msgTypes, 310 | }.Build() 311 | File_example_proto = out.File 312 | file_example_proto_goTypes = nil 313 | file_example_proto_depIdxs = nil 314 | } 315 | -------------------------------------------------------------------------------- /examples/parse-grpc/pb/example.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package pb; 4 | 5 | option go_package = "github.com/metafates/schame/examples/parse-grpc/pb"; 6 | 7 | message Person { 8 | string name = 1; 9 | int32 id = 2; // Unique ID number for this person. 10 | string email = 3; 11 | 12 | enum PhoneType { 13 | MOBILE = 0; 14 | HOME = 1; 15 | WORK = 2; 16 | } 17 | 18 | message PhoneNumber { 19 | string number = 1; 20 | PhoneType type = 2; 21 | } 22 | 23 | repeated PhoneNumber phones = 4; 24 | } 25 | 26 | // Our address book file is just one of these. 27 | message AddressBook { 28 | repeated Person people = 1; 29 | } 30 | -------------------------------------------------------------------------------- /examples/parse-grpc/pb/gen.go: -------------------------------------------------------------------------------- 1 | package pb 2 | 3 | //go:generate protoc --go_out=. --go_opt=paths=source_relative example.proto 4 | -------------------------------------------------------------------------------- /examples/parse/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "time" 7 | 8 | schemajson "github.com/metafates/schema/encoding/json" 9 | "github.com/metafates/schema/optional" 10 | "github.com/metafates/schema/parse" 11 | "github.com/metafates/schema/required" 12 | "github.com/metafates/schema/validate/charset" 13 | ) 14 | 15 | // Let's assume we have the following structure 16 | type User struct { 17 | ID required.UUID[string] 18 | Name required.Charset[string, charset.Print] 19 | Birth optional.InPast[time.Time] 20 | 21 | FavoriteNumber int 22 | 23 | Friends []Friend 24 | } 25 | 26 | type Friend struct { 27 | ID required.UUID[string] 28 | Name required.Charset[string, charset.Print] 29 | } 30 | 31 | func main() { 32 | // We can use json to unmarshal data to it 33 | { 34 | var user User 35 | 36 | const data = ` 37 | { 38 | "ID": "2c376d16-321d-43b3-8648-2e64798cc6b3", 39 | "Name": "john", 40 | "FavoriteNumber": 42, 41 | "Friends": [ 42 | {"ID": "7f735045-c8d2-4a60-9184-0fc033c40a6a", "Name": "jane"} 43 | ] 44 | } 45 | ` 46 | 47 | if err := schemajson.Unmarshal([]byte(data), &user); err != nil { 48 | log.Fatalln(err) 49 | } 50 | 51 | fmt.Println(user.Friends[0].ID.Get()) // 7f735045-c8d2-4a60-9184-0fc033c40a6a 52 | } 53 | 54 | // But we can also construct our user manually through parsing! 55 | // 56 | // For example, we can have some data as map. 57 | // Note, that field names should match go names exactly. Field tags (`json:"myField"`) won't be considered 58 | dataAsMap := map[string]any{ 59 | "ID": "2c376d16-321d-43b3-8648-2e64798cc6b3", 60 | "Name": "john", 61 | "FavoriteNumber": 42, 62 | "Friends": []map[string]any{ 63 | {"ID": "7f735045-c8d2-4a60-9184-0fc033c40a6a", "Name": "jane"}, 64 | }, 65 | } 66 | 67 | // structs are also supported. It is ok to omit non-required fields 68 | dataAsStruct := struct { 69 | ID string 70 | Name []byte // types will be converted, if possible. If not - error is returned 71 | FavoriteNumber uint16 72 | }{ 73 | ID: "2c376d16-321d-43b3-8648-2e64798cc6b3", 74 | Name: []byte("john"), 75 | FavoriteNumber: 42, 76 | } 77 | 78 | // Now let's parse it 79 | for _, data := range []any{ 80 | dataAsMap, 81 | dataAsStruct, 82 | } { 83 | var user User 84 | 85 | if err := parse.Parse(data, &user); err != nil { 86 | log.Fatalln(err) 87 | } 88 | 89 | fmt.Println(user.ID.Get()) // 2c376d16-321d-43b3-8648-2e64798cc6b3 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /examples/tour/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "log" 8 | "time" 9 | 10 | schemajson "github.com/metafates/schema/encoding/json" 11 | "github.com/metafates/schema/optional" 12 | "github.com/metafates/schema/parse" 13 | "github.com/metafates/schema/required" 14 | "github.com/metafates/schema/validate" 15 | "github.com/metafates/schema/validate/charset" 16 | ) 17 | 18 | // Let's assume we have a request which accepts an user 19 | type User struct { 20 | // User name is required and must not be empty string 21 | Name required.NonZero[string] `json:"name"` 22 | 23 | // Birth date is optional, which means it could be null. 24 | // However, if passed, it must be an any valid [time.Time] 25 | Birth optional.Any[time.Time] `json:"birth"` 26 | 27 | // Same for email. It is optional, therefore it could be null. 28 | // But, if passed, not only it must be a valid string, but also a valid RFC 5322 email string 29 | Email optional.Email[string] `json:"email"` 30 | 31 | // Bio is just a regular string. It may be empty, may be not. 32 | // No further logic is attached to it. 33 | Bio string `json:"bio"` 34 | } 35 | 36 | // We could also have an address 37 | type Address struct { 38 | // Latitude is required and must be a valid latitude (range [-90; 90]) 39 | Latitude required.Latitude[float64] `json:"latitude"` 40 | 41 | // Longitude is also required and must be a valid longitude (range [-180; 180]) 42 | Longitude required.Longitude[float64] `json:"longitude"` 43 | } 44 | 45 | // But wait, what about custom types? 46 | // We might want (for some reason) a field which accepts only short strings (<10 bytes). 47 | // Let's see how we might implement it. 48 | 49 | // let's define a custom validator for short strings. 50 | // it should not contain any fields in itself, they won't be initialized or used in any way. 51 | type ShortStr struct{} 52 | 53 | // this function implements a special validator interface 54 | func (ShortStr) Validate(v string) error { 55 | if len(v) >= 10 { 56 | return errors.New("string is too long") 57 | } 58 | 59 | return nil 60 | } 61 | 62 | // That's it, basically. Now we can use this validator in our request. 63 | 64 | // But we can go extreme! It is possible to combine multiple validators using types. 65 | // both ASCII and ShortStr must be satisfied. 66 | // you can also use [validate.Or] to ensure that at least one condition is satisfied. 67 | type ASCIIShortStr = validate.And[ 68 | string, 69 | validate.Charset0[string, charset.ASCII], 70 | ShortStr, 71 | ] 72 | 73 | // Now, our final request may look something like that 74 | type Request struct { 75 | // User is required by default. 76 | // Because, if not passed, it will be empty. 77 | // Therefore required fields in user will also be empty which will result a missing fields error. 78 | User User `json:"user"` 79 | 80 | // Address is, however, optional. It could be null. 81 | // But, if passed, it must be a valid address with respect to its fields validation (required lat/lon) 82 | Address optional.Any[Address] `json:"address"` 83 | 84 | // this is how we can use our validator in custom type. 85 | // we could make an alias for that custom required type, if needed. 86 | MyShortString required.Custom[string, ShortStr] `json:"myShortString"` 87 | 88 | // same as for [Request.MyShortString] but using an optional instead. 89 | ASCIIShortString optional.Custom[string, ASCIIShortStr] `json:"asciiShortString"` 90 | 91 | PermitBio bool `json:"permitBio"` 92 | } 93 | 94 | // - "How do I do cross-field validation?" 95 | // - Implement [validate.Validateable] interface for your struct 96 | // 97 | // This method will be called AFTER required and optional fields are validated. 98 | // It is optional - you may skip defining it if you don't need to. 99 | func (r *Request) Validate() error { 100 | if !r.PermitBio { 101 | if r.User.Bio != "" { 102 | return errors.New("bio is not permitted") 103 | } 104 | } 105 | 106 | return nil 107 | } 108 | 109 | func main() { 110 | // here we create a VALID json for our request. we will try to pass an invalid one later. 111 | data := []byte(` 112 | { 113 | "user": { 114 | "name": "john", 115 | "email": "john@example.com (comment)", 116 | "bio": "lorem ipsum" 117 | }, 118 | "address": { 119 | "latitude": 81.111, 120 | "longitude": 100.101 121 | }, 122 | "myShortString": "foo", 123 | "permitBio": true 124 | }`) 125 | 126 | var request Request 127 | 128 | { 129 | // let's unmarshal it. we can use anything we want, not only json 130 | if err := json.Unmarshal(data, &request); err != nil { 131 | log.Fatalln(err) 132 | } 133 | 134 | // but validation won't happen just yet. we need to invoke it manually 135 | // (passing pointer to validate is required to maintain certain guarantees later) 136 | if err := validate.Validate(&request); err != nil { 137 | log.Fatalln(err) 138 | } 139 | // that's it, our struct was validated successfully! 140 | // no errors yet, but we will get there 141 | // 142 | // remember our custom cross-field validation? 143 | // it was called as part of this function 144 | } 145 | 146 | { 147 | // we could also use a helper function that does *exactly that* for us. 148 | if err := schemajson.Unmarshal(data, &request); err != nil { 149 | log.Fatalln(err) 150 | } 151 | } 152 | 153 | // now that we have successfully unmarshalled our json, we can use request fields. 154 | // to access values of our schema-guarded fields we can use Get() method 155 | // 156 | // NOTE: calling this method BEFORE we have 157 | // validated our request will panic intentionally. 158 | fmt.Println(request.User.Name.Get()) // output: john 159 | 160 | // optional values return a tuple: a value and a boolean stating its presence 161 | email, ok := request.User.Email.Get() 162 | fmt.Println(email, ok) // output: john@example.com (comment) true 163 | 164 | // birth is missing so "ok" will be false 165 | birth, ok := request.User.Birth.Get() 166 | fmt.Println(birth, ok) // output: 0001-01-01 00:00:00 +0000 UTC false 167 | 168 | // let's try to pass an INVALID jsons. 169 | invalidEmail := []byte(` 170 | { 171 | "user": { 172 | "name": "john", 173 | "email": "john@@@example.com", 174 | "bio": "lorem ipsum" 175 | }, 176 | "address": { 177 | "latitude": 81.111, 178 | "longitude": 100.101 179 | }, 180 | "myShortString": "foo", 181 | "permitBio": true 182 | }`) 183 | 184 | invalidShortStr := []byte(` 185 | { 186 | "user": { 187 | "name": "john", 188 | "email": "john@example.com", 189 | "bio": "lorem ipsum" 190 | }, 191 | "address": { 192 | "latitude": 81.111, 193 | "longitude": 100.101 194 | }, 195 | "myShortString": "super long string that shall not pass!!!!!!!!", 196 | "permitBio": true 197 | }`) 198 | 199 | missingUserName := []byte(` 200 | { 201 | "user": { 202 | "email": "john@example.com", 203 | "bio": "lorem ipsum" 204 | }, 205 | "address": { 206 | "latitude": 81.111, 207 | "longitude": 100.101 208 | }, 209 | "myShortString": "foo", 210 | "permitBio": true 211 | }`) 212 | 213 | bioNotPermitted := []byte(` 214 | { 215 | "user": { 216 | "name": "john", 217 | "email": "john@example.com", 218 | "bio": "lorem ipsum" 219 | }, 220 | "address": { 221 | "latitude": 81.111, 222 | "longitude": 100.101 223 | }, 224 | "myShortString": "foo", 225 | "permitBio": false 226 | }`) 227 | 228 | fmt.Println(schemajson.Unmarshal(invalidEmail, new(Request))) 229 | // validate: .User.Email: mail: missing '@' or angle-addr 230 | 231 | fmt.Println(schemajson.Unmarshal(invalidShortStr, new(Request))) 232 | // validate: .MyShortString: string is too long 233 | 234 | fmt.Println(schemajson.Unmarshal(missingUserName, new(Request))) 235 | // validate: .User.Name: missing value 236 | 237 | fmt.Println(schemajson.Unmarshal(bioNotPermitted, new(Request))) 238 | // validate: bio is not permitted 239 | 240 | // You can check if it was validation error or any other json error. 241 | err := schemajson.Unmarshal(missingUserName, new(Request)) 242 | 243 | var validationErr validate.ValidationError 244 | if errors.As(err, &validationErr) { 245 | fmt.Println("error while validating", validationErr.Path()) 246 | // error while validating .User.Name 247 | 248 | fmt.Println(errors.Is(err, required.ErrMissingValue)) 249 | // true 250 | } 251 | 252 | // one more feature - parsing! 253 | // not all data comes from json - sometimes we already have some initialized values (structs, maps) as go values. 254 | // 255 | // for example, we may have some generated code with gRPC and we want to validate it. 256 | // what we can do with this library: 257 | 258 | // first, let's define some structure 259 | type Example struct { 260 | ID required.UUID[string] 261 | Content string 262 | Tags required.NonEmptySlice[string] 263 | RandomNumber float64 264 | } 265 | 266 | // second, let's assume we have the following grpc message generated 267 | type GRPCExample struct { 268 | ID string 269 | Content string 270 | Tags []string 271 | RandomNumber int8 // yes, the types are different, but they will be converted 272 | } 273 | 274 | // third, parse it (field names must match exactly) 275 | var example Example 276 | 277 | err = parse.Parse( 278 | GRPCExample{ 279 | ID: "03973e64-358c-4a26-b095-150f18e8bfe7", 280 | Content: "lorem ipsum", 281 | Tags: []string{"foo", "bar"}, 282 | RandomNumber: 9, 283 | }, 284 | &example, 285 | 286 | // this function also accepts variadic options. 287 | // here we say that unknown fields will result parsing error (by default they won't, just like json) 288 | parse.WithDisallowUnknownFields(), 289 | ) 290 | if err != nil { 291 | log.Fatalln(err) 292 | } 293 | 294 | // parsed values are already validated, therefore we can use it 295 | fmt.Println(example.ID.Get()) 296 | // Output: 03973e64-358c-4a26-b095-150f18e8bfe7 297 | 298 | // By the way, parsing from maps and slices is also supported. 299 | } 300 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/metafates/schema 2 | 3 | go 1.24.2 4 | 5 | require ( 6 | github.com/dave/jennifer v1.7.1 7 | golang.org/x/tools v0.32.0 8 | google.golang.org/protobuf v1.36.6 9 | ) 10 | 11 | require ( 12 | golang.org/x/mod v0.24.0 // indirect 13 | golang.org/x/sync v0.13.0 // indirect 14 | ) 15 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/dave/jennifer v1.7.1 h1:B4jJJDHelWcDhlRQxWeo0Npa/pYKBLrirAQoTN45txo= 2 | github.com/dave/jennifer v1.7.1/go.mod h1:nXbxhEmQfOZhWml3D1cDK5M1FLnMSozpbFN/m3RmGZc= 3 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 4 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 5 | golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= 6 | golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= 7 | golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= 8 | golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 9 | golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU= 10 | golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s= 11 | google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= 12 | google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= 13 | -------------------------------------------------------------------------------- /internal/iso/countries.go: -------------------------------------------------------------------------------- 1 | // Code generated by gen.py from csv; DO NOT EDIT. 2 | package iso 3 | 4 | var CountryAlpha2 = map[string]struct{}{ 5 | "af": {}, 6 | "ax": {}, 7 | "al": {}, 8 | "dz": {}, 9 | "as": {}, 10 | "ad": {}, 11 | "ao": {}, 12 | "ai": {}, 13 | "aq": {}, 14 | "ag": {}, 15 | "ar": {}, 16 | "am": {}, 17 | "aw": {}, 18 | "au": {}, 19 | "at": {}, 20 | "az": {}, 21 | "bs": {}, 22 | "bh": {}, 23 | "bd": {}, 24 | "bb": {}, 25 | "by": {}, 26 | "be": {}, 27 | "bz": {}, 28 | "bj": {}, 29 | "bm": {}, 30 | "bt": {}, 31 | "bo": {}, 32 | "bq": {}, 33 | "ba": {}, 34 | "bw": {}, 35 | "bv": {}, 36 | "br": {}, 37 | "io": {}, 38 | "vg": {}, 39 | "bn": {}, 40 | "bg": {}, 41 | "bf": {}, 42 | "bi": {}, 43 | "cv": {}, 44 | "kh": {}, 45 | "cm": {}, 46 | "ca": {}, 47 | "ky": {}, 48 | "cf": {}, 49 | "td": {}, 50 | "cl": {}, 51 | "cn": {}, 52 | "hk": {}, 53 | "mo": {}, 54 | "cx": {}, 55 | "cc": {}, 56 | "co": {}, 57 | "km": {}, 58 | "cg": {}, 59 | "ck": {}, 60 | "cr": {}, 61 | "hr": {}, 62 | "cu": {}, 63 | "cw": {}, 64 | "cy": {}, 65 | "cz": {}, 66 | "kp": {}, 67 | "cd": {}, 68 | "dk": {}, 69 | "dj": {}, 70 | "dm": {}, 71 | "do": {}, 72 | "ec": {}, 73 | "eg": {}, 74 | "sv": {}, 75 | "gq": {}, 76 | "er": {}, 77 | "ee": {}, 78 | "sz": {}, 79 | "et": {}, 80 | "fk": {}, 81 | "fo": {}, 82 | "fj": {}, 83 | "fi": {}, 84 | "fr": {}, 85 | "gf": {}, 86 | "pf": {}, 87 | "tf": {}, 88 | "ga": {}, 89 | "gm": {}, 90 | "ge": {}, 91 | "de": {}, 92 | "gh": {}, 93 | "gi": {}, 94 | "gr": {}, 95 | "gl": {}, 96 | "gd": {}, 97 | "gp": {}, 98 | "gu": {}, 99 | "gt": {}, 100 | "gg": {}, 101 | "gn": {}, 102 | "gw": {}, 103 | "gy": {}, 104 | "ht": {}, 105 | "hm": {}, 106 | "va": {}, 107 | "hn": {}, 108 | "hu": {}, 109 | "is": {}, 110 | "in": {}, 111 | "id": {}, 112 | "ir": {}, 113 | "iq": {}, 114 | "ie": {}, 115 | "im": {}, 116 | "il": {}, 117 | "it": {}, 118 | "ci": {}, 119 | "jm": {}, 120 | "jp": {}, 121 | "je": {}, 122 | "jo": {}, 123 | "kz": {}, 124 | "ke": {}, 125 | "ki": {}, 126 | "kw": {}, 127 | "kg": {}, 128 | "la": {}, 129 | "lv": {}, 130 | "lb": {}, 131 | "ls": {}, 132 | "lr": {}, 133 | "ly": {}, 134 | "li": {}, 135 | "lt": {}, 136 | "lu": {}, 137 | "mg": {}, 138 | "mw": {}, 139 | "my": {}, 140 | "mv": {}, 141 | "ml": {}, 142 | "mt": {}, 143 | "mh": {}, 144 | "mq": {}, 145 | "mr": {}, 146 | "mu": {}, 147 | "yt": {}, 148 | "mx": {}, 149 | "fm": {}, 150 | "mc": {}, 151 | "mn": {}, 152 | "me": {}, 153 | "ms": {}, 154 | "ma": {}, 155 | "mz": {}, 156 | "mm": {}, 157 | "na": {}, 158 | "nr": {}, 159 | "np": {}, 160 | "nl": {}, 161 | "nc": {}, 162 | "nz": {}, 163 | "ni": {}, 164 | "ne": {}, 165 | "ng": {}, 166 | "nu": {}, 167 | "nf": {}, 168 | "mp": {}, 169 | "mk": {}, 170 | "no": {}, 171 | "om": {}, 172 | "pk": {}, 173 | "pw": {}, 174 | "pa": {}, 175 | "pg": {}, 176 | "py": {}, 177 | "pe": {}, 178 | "ph": {}, 179 | "pn": {}, 180 | "pl": {}, 181 | "pt": {}, 182 | "pr": {}, 183 | "qa": {}, 184 | "kr": {}, 185 | "md": {}, 186 | "re": {}, 187 | "ro": {}, 188 | "ru": {}, 189 | "rw": {}, 190 | "bl": {}, 191 | "sh": {}, 192 | "kn": {}, 193 | "lc": {}, 194 | "mf": {}, 195 | "pm": {}, 196 | "vc": {}, 197 | "ws": {}, 198 | "sm": {}, 199 | "st": {}, 200 | "sa": {}, 201 | "sn": {}, 202 | "rs": {}, 203 | "sc": {}, 204 | "sl": {}, 205 | "sg": {}, 206 | "sx": {}, 207 | "sk": {}, 208 | "si": {}, 209 | "sb": {}, 210 | "so": {}, 211 | "za": {}, 212 | "gs": {}, 213 | "ss": {}, 214 | "es": {}, 215 | "lk": {}, 216 | "ps": {}, 217 | "sd": {}, 218 | "sr": {}, 219 | "sj": {}, 220 | "se": {}, 221 | "ch": {}, 222 | "sy": {}, 223 | "tw": {}, 224 | "tj": {}, 225 | "th": {}, 226 | "tl": {}, 227 | "tg": {}, 228 | "tk": {}, 229 | "to": {}, 230 | "tt": {}, 231 | "tn": {}, 232 | "tr": {}, 233 | "tm": {}, 234 | "tc": {}, 235 | "tv": {}, 236 | "ug": {}, 237 | "ua": {}, 238 | "ae": {}, 239 | "gb": {}, 240 | "tz": {}, 241 | "um": {}, 242 | "us": {}, 243 | "vi": {}, 244 | "uy": {}, 245 | "uz": {}, 246 | "vu": {}, 247 | "ve": {}, 248 | "vn": {}, 249 | "wf": {}, 250 | "eh": {}, 251 | "ye": {}, 252 | "zm": {}, 253 | "zw": {}, 254 | } 255 | 256 | var CountryAlpha3 = map[string]struct{}{ 257 | "afg": {}, 258 | "ala": {}, 259 | "alb": {}, 260 | "dza": {}, 261 | "asm": {}, 262 | "and": {}, 263 | "ago": {}, 264 | "aia": {}, 265 | "ata": {}, 266 | "atg": {}, 267 | "arg": {}, 268 | "arm": {}, 269 | "abw": {}, 270 | "aus": {}, 271 | "aut": {}, 272 | "aze": {}, 273 | "bhs": {}, 274 | "bhr": {}, 275 | "bgd": {}, 276 | "brb": {}, 277 | "blr": {}, 278 | "bel": {}, 279 | "blz": {}, 280 | "ben": {}, 281 | "bmu": {}, 282 | "btn": {}, 283 | "bol": {}, 284 | "bes": {}, 285 | "bih": {}, 286 | "bwa": {}, 287 | "bvt": {}, 288 | "bra": {}, 289 | "iot": {}, 290 | "vgb": {}, 291 | "brn": {}, 292 | "bgr": {}, 293 | "bfa": {}, 294 | "bdi": {}, 295 | "cpv": {}, 296 | "khm": {}, 297 | "cmr": {}, 298 | "can": {}, 299 | "cym": {}, 300 | "caf": {}, 301 | "tcd": {}, 302 | "chl": {}, 303 | "chn": {}, 304 | "hkg": {}, 305 | "mac": {}, 306 | "cxr": {}, 307 | "cck": {}, 308 | "col": {}, 309 | "com": {}, 310 | "cog": {}, 311 | "cok": {}, 312 | "cri": {}, 313 | "hrv": {}, 314 | "cub": {}, 315 | "cuw": {}, 316 | "cyp": {}, 317 | "cze": {}, 318 | "prk": {}, 319 | "cod": {}, 320 | "dnk": {}, 321 | "dji": {}, 322 | "dma": {}, 323 | "dom": {}, 324 | "ecu": {}, 325 | "egy": {}, 326 | "slv": {}, 327 | "gnq": {}, 328 | "eri": {}, 329 | "est": {}, 330 | "swz": {}, 331 | "eth": {}, 332 | "flk": {}, 333 | "fro": {}, 334 | "fji": {}, 335 | "fin": {}, 336 | "fra": {}, 337 | "guf": {}, 338 | "pyf": {}, 339 | "atf": {}, 340 | "gab": {}, 341 | "gmb": {}, 342 | "geo": {}, 343 | "deu": {}, 344 | "gha": {}, 345 | "gib": {}, 346 | "grc": {}, 347 | "grl": {}, 348 | "grd": {}, 349 | "glp": {}, 350 | "gum": {}, 351 | "gtm": {}, 352 | "ggy": {}, 353 | "gin": {}, 354 | "gnb": {}, 355 | "guy": {}, 356 | "hti": {}, 357 | "hmd": {}, 358 | "vat": {}, 359 | "hnd": {}, 360 | "hun": {}, 361 | "isl": {}, 362 | "ind": {}, 363 | "idn": {}, 364 | "irn": {}, 365 | "irq": {}, 366 | "irl": {}, 367 | "imn": {}, 368 | "isr": {}, 369 | "ita": {}, 370 | "civ": {}, 371 | "jam": {}, 372 | "jpn": {}, 373 | "jey": {}, 374 | "jor": {}, 375 | "kaz": {}, 376 | "ken": {}, 377 | "kir": {}, 378 | "kwt": {}, 379 | "kgz": {}, 380 | "lao": {}, 381 | "lva": {}, 382 | "lbn": {}, 383 | "lso": {}, 384 | "lbr": {}, 385 | "lby": {}, 386 | "lie": {}, 387 | "ltu": {}, 388 | "lux": {}, 389 | "mdg": {}, 390 | "mwi": {}, 391 | "mys": {}, 392 | "mdv": {}, 393 | "mli": {}, 394 | "mlt": {}, 395 | "mhl": {}, 396 | "mtq": {}, 397 | "mrt": {}, 398 | "mus": {}, 399 | "myt": {}, 400 | "mex": {}, 401 | "fsm": {}, 402 | "mco": {}, 403 | "mng": {}, 404 | "mne": {}, 405 | "msr": {}, 406 | "mar": {}, 407 | "moz": {}, 408 | "mmr": {}, 409 | "nam": {}, 410 | "nru": {}, 411 | "npl": {}, 412 | "nld": {}, 413 | "ncl": {}, 414 | "nzl": {}, 415 | "nic": {}, 416 | "ner": {}, 417 | "nga": {}, 418 | "niu": {}, 419 | "nfk": {}, 420 | "mnp": {}, 421 | "mkd": {}, 422 | "nor": {}, 423 | "omn": {}, 424 | "pak": {}, 425 | "plw": {}, 426 | "pan": {}, 427 | "png": {}, 428 | "pry": {}, 429 | "per": {}, 430 | "phl": {}, 431 | "pcn": {}, 432 | "pol": {}, 433 | "prt": {}, 434 | "pri": {}, 435 | "qat": {}, 436 | "kor": {}, 437 | "mda": {}, 438 | "reu": {}, 439 | "rou": {}, 440 | "rus": {}, 441 | "rwa": {}, 442 | "blm": {}, 443 | "shn": {}, 444 | "kna": {}, 445 | "lca": {}, 446 | "maf": {}, 447 | "spm": {}, 448 | "vct": {}, 449 | "wsm": {}, 450 | "smr": {}, 451 | "stp": {}, 452 | "sau": {}, 453 | "sen": {}, 454 | "srb": {}, 455 | "syc": {}, 456 | "sle": {}, 457 | "sgp": {}, 458 | "sxm": {}, 459 | "svk": {}, 460 | "svn": {}, 461 | "slb": {}, 462 | "som": {}, 463 | "zaf": {}, 464 | "sgs": {}, 465 | "ssd": {}, 466 | "esp": {}, 467 | "lka": {}, 468 | "pse": {}, 469 | "sdn": {}, 470 | "sur": {}, 471 | "sjm": {}, 472 | "swe": {}, 473 | "che": {}, 474 | "syr": {}, 475 | "twn": {}, 476 | "tjk": {}, 477 | "tha": {}, 478 | "tls": {}, 479 | "tgo": {}, 480 | "tkl": {}, 481 | "ton": {}, 482 | "tto": {}, 483 | "tun": {}, 484 | "tur": {}, 485 | "tkm": {}, 486 | "tca": {}, 487 | "tuv": {}, 488 | "uga": {}, 489 | "ukr": {}, 490 | "are": {}, 491 | "gbr": {}, 492 | "tza": {}, 493 | "umi": {}, 494 | "usa": {}, 495 | "vir": {}, 496 | "ury": {}, 497 | "uzb": {}, 498 | "vut": {}, 499 | "ven": {}, 500 | "vnm": {}, 501 | "wlf": {}, 502 | "esh": {}, 503 | "yem": {}, 504 | "zmb": {}, 505 | "zwe": {}, 506 | } 507 | -------------------------------------------------------------------------------- /internal/iso/currencies.go: -------------------------------------------------------------------------------- 1 | // Code generated by gen.py from csv; DO NOT EDIT. 2 | package iso 3 | 4 | var CurrencyAlpha = map[string]struct{}{ 5 | "afn": {}, 6 | "eur": {}, 7 | "all": {}, 8 | "dzd": {}, 9 | "usd": {}, 10 | "aoa": {}, 11 | "xcd": {}, 12 | "ars": {}, 13 | "amd": {}, 14 | "awg": {}, 15 | "aud": {}, 16 | "azn": {}, 17 | "bsd": {}, 18 | "bhd": {}, 19 | "bdt": {}, 20 | "bbd": {}, 21 | "byn": {}, 22 | "bzd": {}, 23 | "xof": {}, 24 | "bmd": {}, 25 | "inr": {}, 26 | "btn": {}, 27 | "bob": {}, 28 | "bov": {}, 29 | "bam": {}, 30 | "bwp": {}, 31 | "nok": {}, 32 | "brl": {}, 33 | "bnd": {}, 34 | "bgn": {}, 35 | "bif": {}, 36 | "cve": {}, 37 | "khr": {}, 38 | "xaf": {}, 39 | "cad": {}, 40 | "kyd": {}, 41 | "clp": {}, 42 | "clf": {}, 43 | "cny": {}, 44 | "cop": {}, 45 | "cou": {}, 46 | "kmf": {}, 47 | "cdf": {}, 48 | "nzd": {}, 49 | "crc": {}, 50 | "cup": {}, 51 | "xcg": {}, 52 | "czk": {}, 53 | "dkk": {}, 54 | "djf": {}, 55 | "dop": {}, 56 | "egp": {}, 57 | "svc": {}, 58 | "ern": {}, 59 | "szl": {}, 60 | "etb": {}, 61 | "fkp": {}, 62 | "fjd": {}, 63 | "xpf": {}, 64 | "gmd": {}, 65 | "gel": {}, 66 | "ghs": {}, 67 | "gip": {}, 68 | "gtq": {}, 69 | "gbp": {}, 70 | "gnf": {}, 71 | "gyd": {}, 72 | "htg": {}, 73 | "hnl": {}, 74 | "hkd": {}, 75 | "huf": {}, 76 | "isk": {}, 77 | "idr": {}, 78 | "xdr": {}, 79 | "irr": {}, 80 | "iqd": {}, 81 | "ils": {}, 82 | "jmd": {}, 83 | "jpy": {}, 84 | "jod": {}, 85 | "kzt": {}, 86 | "kes": {}, 87 | "kpw": {}, 88 | "krw": {}, 89 | "kwd": {}, 90 | "kgs": {}, 91 | "lak": {}, 92 | "lbp": {}, 93 | "lsl": {}, 94 | "zar": {}, 95 | "lrd": {}, 96 | "lyd": {}, 97 | "chf": {}, 98 | "mop": {}, 99 | "mkd": {}, 100 | "mga": {}, 101 | "mwk": {}, 102 | "myr": {}, 103 | "mvr": {}, 104 | "mru": {}, 105 | "mur": {}, 106 | "xua": {}, 107 | "mxn": {}, 108 | "mxv": {}, 109 | "mdl": {}, 110 | "mnt": {}, 111 | "mad": {}, 112 | "mzn": {}, 113 | "mmk": {}, 114 | "nad": {}, 115 | "npr": {}, 116 | "nio": {}, 117 | "ngn": {}, 118 | "omr": {}, 119 | "pkr": {}, 120 | "pab": {}, 121 | "pgk": {}, 122 | "pyg": {}, 123 | "pen": {}, 124 | "php": {}, 125 | "pln": {}, 126 | "qar": {}, 127 | "ron": {}, 128 | "rub": {}, 129 | "rwf": {}, 130 | "shp": {}, 131 | "wst": {}, 132 | "stn": {}, 133 | "sar": {}, 134 | "rsd": {}, 135 | "scr": {}, 136 | "sle": {}, 137 | "sgd": {}, 138 | "xsu": {}, 139 | "sbd": {}, 140 | "sos": {}, 141 | "ssp": {}, 142 | "lkr": {}, 143 | "sdg": {}, 144 | "srd": {}, 145 | "sek": {}, 146 | "che": {}, 147 | "chw": {}, 148 | "syp": {}, 149 | "twd": {}, 150 | "tjs": {}, 151 | "tzs": {}, 152 | "thb": {}, 153 | "top": {}, 154 | "ttd": {}, 155 | "tnd": {}, 156 | "try": {}, 157 | "tmt": {}, 158 | "ugx": {}, 159 | "uah": {}, 160 | "aed": {}, 161 | "usn": {}, 162 | "uyu": {}, 163 | "uyi": {}, 164 | "uyw": {}, 165 | "uzs": {}, 166 | "vuv": {}, 167 | "ves": {}, 168 | "ved": {}, 169 | "vnd": {}, 170 | "yer": {}, 171 | "zmw": {}, 172 | "zwg": {}, 173 | "xba": {}, 174 | "xbb": {}, 175 | "xbc": {}, 176 | "xbd": {}, 177 | "xts": {}, 178 | "xxx": {}, 179 | "xau": {}, 180 | "xpd": {}, 181 | "xpt": {}, 182 | "xag": {}, 183 | "afa": {}, 184 | "fim": {}, 185 | "alk": {}, 186 | "adp": {}, 187 | "esp": {}, 188 | "frf": {}, 189 | "aok": {}, 190 | "aon": {}, 191 | "aor": {}, 192 | "ara": {}, 193 | "arp": {}, 194 | "ary": {}, 195 | "rur": {}, 196 | "ats": {}, 197 | "aym": {}, 198 | "azm": {}, 199 | "byb": {}, 200 | "byr": {}, 201 | "bec": {}, 202 | "bef": {}, 203 | "bel": {}, 204 | "bop": {}, 205 | "bad": {}, 206 | "brb": {}, 207 | "brc": {}, 208 | "bre": {}, 209 | "brn": {}, 210 | "brr": {}, 211 | "bgj": {}, 212 | "bgk": {}, 213 | "bgl": {}, 214 | "buk": {}, 215 | "hrd": {}, 216 | "hrk": {}, 217 | "cuc": {}, 218 | "ang": {}, 219 | "cyp": {}, 220 | "csj": {}, 221 | "csk": {}, 222 | "ecs": {}, 223 | "ecv": {}, 224 | "gqe": {}, 225 | "eek": {}, 226 | "xeu": {}, 227 | "gek": {}, 228 | "ddm": {}, 229 | "dem": {}, 230 | "ghc": {}, 231 | "ghp": {}, 232 | "grd": {}, 233 | "gne": {}, 234 | "gns": {}, 235 | "gwe": {}, 236 | "gwp": {}, 237 | "itl": {}, 238 | "isj": {}, 239 | "iep": {}, 240 | "ilp": {}, 241 | "ilr": {}, 242 | "laj": {}, 243 | "lvl": {}, 244 | "lvr": {}, 245 | "lsm": {}, 246 | "zal": {}, 247 | "ltl": {}, 248 | "ltt": {}, 249 | "luc": {}, 250 | "luf": {}, 251 | "lul": {}, 252 | "mgf": {}, 253 | "mvq": {}, 254 | "mlf": {}, 255 | "mtl": {}, 256 | "mtp": {}, 257 | "mro": {}, 258 | "mxp": {}, 259 | "mze": {}, 260 | "mzm": {}, 261 | "nlg": {}, 262 | "nic": {}, 263 | "peh": {}, 264 | "pei": {}, 265 | "pes": {}, 266 | "plz": {}, 267 | "pte": {}, 268 | "rok": {}, 269 | "rol": {}, 270 | "std": {}, 271 | "csd": {}, 272 | "sll": {}, 273 | "skk": {}, 274 | "sit": {}, 275 | "rhd": {}, 276 | "esa": {}, 277 | "esb": {}, 278 | "sdd": {}, 279 | "sdp": {}, 280 | "srg": {}, 281 | "chc": {}, 282 | "tjr": {}, 283 | "tpe": {}, 284 | "trl": {}, 285 | "tmm": {}, 286 | "ugs": {}, 287 | "ugw": {}, 288 | "uak": {}, 289 | "sur": {}, 290 | "uss": {}, 291 | "uyn": {}, 292 | "uyp": {}, 293 | "veb": {}, 294 | "vef": {}, 295 | "vnc": {}, 296 | "ydd": {}, 297 | "yud": {}, 298 | "yum": {}, 299 | "yun": {}, 300 | "zrn": {}, 301 | "zrz": {}, 302 | "zmk": {}, 303 | "zwc": {}, 304 | "zwd": {}, 305 | "zwn": {}, 306 | "zwr": {}, 307 | "zwl": {}, 308 | "xfo": {}, 309 | "xre": {}, 310 | "xfu": {}, 311 | } 312 | -------------------------------------------------------------------------------- /internal/iso/gen.go: -------------------------------------------------------------------------------- 1 | package iso 2 | 3 | //go:generate python3 gen.py 4 | -------------------------------------------------------------------------------- /internal/iso/gen.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import csv 4 | from typing import TYPE_CHECKING 5 | 6 | if TYPE_CHECKING: 7 | from _typeshed import SupportsWrite 8 | 9 | # go package name 10 | PKG = "iso" 11 | 12 | # https://raw.githubusercontent.com/datasets/country-codes/refs/heads/main/data/country-codes.csv 13 | COUNTRIES = "countries.csv" 14 | COUNTRIES_OUT = "countries.go" 15 | 16 | # https://raw.githubusercontent.com/datasets/currency-codes/refs/heads/main/data/codes-all.csv 17 | CURRENCIES = "currencies.csv" 18 | CURRENCIES_OUT = "currencies.go" 19 | 20 | # https://raw.githubusercontent.com/datasets/language-codes/refs/heads/main/data/language-codes-3b2.csv 21 | LANGUAGES = "languages.csv" 22 | LANGUAGES_OUT = "languages.go" 23 | 24 | 25 | PREAMBLE = "// Code generated by gen.py from csv; DO NOT EDIT." 26 | 27 | 28 | def make_p(f: SupportsWrite[str]): 29 | def p(*s: object): 30 | print(*s, file=f) 31 | 32 | return p 33 | 34 | 35 | def read_csv(name: str) -> list[dict[str, str]]: 36 | data: list[dict[str, str]] = [] 37 | 38 | with open(name, newline='') as csvfile: 39 | reader = csv.reader(csvfile, delimiter=',', quotechar='"') 40 | header = next(reader) 41 | 42 | 43 | for row in reader: 44 | row = dict(zip(header, row)) 45 | 46 | data.append(row) 47 | 48 | return data 49 | 50 | 51 | def gen_countries(): 52 | data = read_csv(COUNTRIES) 53 | 54 | with open(COUNTRIES_OUT, "w") as f: 55 | p = make_p(f) 56 | 57 | p(PREAMBLE) 58 | p(f"package {PKG}") 59 | p() 60 | p("var CountryAlpha2 = map[string]struct{}{") 61 | for row in data: 62 | alpha2 = row["ISO3166-1-Alpha-2"].lower() 63 | p(f'\t"{alpha2}": {{}},') 64 | p("}") 65 | p() 66 | p("var CountryAlpha3 = map[string]struct{}{") 67 | for row in data: 68 | alpha3 = row["ISO3166-1-Alpha-3"].lower() 69 | p(f'\t"{alpha3}": {{}},') 70 | p("}") 71 | 72 | 73 | def gen_currencies(): 74 | data = read_csv(CURRENCIES) 75 | 76 | with open(CURRENCIES_OUT, "w") as f: 77 | p = make_p(f) 78 | 79 | visited: set[str] = set() 80 | 81 | p(PREAMBLE) 82 | p(f"package {PKG}") 83 | p() 84 | p("var CurrencyAlpha = map[string]struct{}{") 85 | for row in data: 86 | code = row["AlphabeticCode"].lower() 87 | 88 | if code not in visited and code != "": 89 | p(f'\t"{code}": {{}},') 90 | visited.add(code) 91 | p("}") 92 | 93 | 94 | def gen_languages(): 95 | data = read_csv(LANGUAGES) 96 | 97 | with open(LANGUAGES_OUT, "w") as f: 98 | p = make_p(f) 99 | 100 | p(PREAMBLE) 101 | p(f"package {PKG}") 102 | p() 103 | p("var LanguageAlpha2 = map[string]struct{}{") 104 | for row in data: 105 | alpha2 = row["alpha2"].lower() 106 | p(f'\t"{alpha2}": {{}},') 107 | p("}") 108 | p() 109 | p("var LanguageAlpha3 = map[string]struct{}{") 110 | for row in data: 111 | alpha3 = row["alpha3-b"].lower() 112 | p(f'\t"{alpha3}": {{}},') 113 | p("}") 114 | 115 | 116 | def main(): 117 | gen_countries() 118 | gen_currencies() 119 | gen_languages() 120 | 121 | 122 | if __name__ == "__main__": 123 | main() 124 | -------------------------------------------------------------------------------- /internal/iso/languages.csv: -------------------------------------------------------------------------------- 1 | "alpha3-b","alpha2","English" 2 | "aar","aa","Afar" 3 | "abk","ab","Abkhazian" 4 | "afr","af","Afrikaans" 5 | "aka","ak","Akan" 6 | "alb","sq","Albanian" 7 | "amh","am","Amharic" 8 | "ara","ar","Arabic" 9 | "arg","an","Aragonese" 10 | "arm","hy","Armenian" 11 | "asm","as","Assamese" 12 | "ava","av","Avaric" 13 | "ave","ae","Avestan" 14 | "aym","ay","Aymara" 15 | "aze","az","Azerbaijani" 16 | "bak","ba","Bashkir" 17 | "bam","bm","Bambara" 18 | "baq","eu","Basque" 19 | "bel","be","Belarusian" 20 | "ben","bn","Bengali" 21 | "bis","bi","Bislama" 22 | "bos","bs","Bosnian" 23 | "bre","br","Breton" 24 | "bul","bg","Bulgarian" 25 | "bur","my","Burmese" 26 | "cat","ca","Catalan; Valencian" 27 | "cha","ch","Chamorro" 28 | "che","ce","Chechen" 29 | "chi","zh","Chinese" 30 | "chu","cu","Church Slavic; Old Slavonic; Church Slavonic; Old Bulgarian; Old Church Slavonic" 31 | "chv","cv","Chuvash" 32 | "cor","kw","Cornish" 33 | "cos","co","Corsican" 34 | "cre","cr","Cree" 35 | "cze","cs","Czech" 36 | "dan","da","Danish" 37 | "div","dv","Divehi; Dhivehi; Maldivian" 38 | "dut","nl","Dutch; Flemish" 39 | "dzo","dz","Dzongkha" 40 | "eng","en","English" 41 | "epo","eo","Esperanto" 42 | "est","et","Estonian" 43 | "ewe","ee","Ewe" 44 | "fao","fo","Faroese" 45 | "fij","fj","Fijian" 46 | "fin","fi","Finnish" 47 | "fre","fr","French" 48 | "fry","fy","Western Frisian" 49 | "ful","ff","Fulah" 50 | "geo","ka","Georgian" 51 | "ger","de","German" 52 | "gla","gd","Gaelic; Scottish Gaelic" 53 | "gle","ga","Irish" 54 | "glg","gl","Galician" 55 | "glv","gv","Manx" 56 | "gre","el","Greek, Modern (1453-)" 57 | "grn","gn","Guarani" 58 | "guj","gu","Gujarati" 59 | "hat","ht","Haitian; Haitian Creole" 60 | "hau","ha","Hausa" 61 | "heb","he","Hebrew" 62 | "her","hz","Herero" 63 | "hin","hi","Hindi" 64 | "hmo","ho","Hiri Motu" 65 | "hrv","hr","Croatian" 66 | "hun","hu","Hungarian" 67 | "ibo","ig","Igbo" 68 | "ice","is","Icelandic" 69 | "ido","io","Ido" 70 | "iii","ii","Sichuan Yi; Nuosu" 71 | "iku","iu","Inuktitut" 72 | "ile","ie","Interlingue; Occidental" 73 | "ina","ia","Interlingua (International Auxiliary Language Association)" 74 | "ind","id","Indonesian" 75 | "ipk","ik","Inupiaq" 76 | "ita","it","Italian" 77 | "jav","jv","Javanese" 78 | "jpn","ja","Japanese" 79 | "kal","kl","Kalaallisut; Greenlandic" 80 | "kan","kn","Kannada" 81 | "kas","ks","Kashmiri" 82 | "kau","kr","Kanuri" 83 | "kaz","kk","Kazakh" 84 | "khm","km","Central Khmer" 85 | "kik","ki","Kikuyu; Gikuyu" 86 | "kin","rw","Kinyarwanda" 87 | "kir","ky","Kirghiz; Kyrgyz" 88 | "kom","kv","Komi" 89 | "kon","kg","Kongo" 90 | "kor","ko","Korean" 91 | "kua","kj","Kuanyama; Kwanyama" 92 | "kur","ku","Kurdish" 93 | "lao","lo","Lao" 94 | "lat","la","Latin" 95 | "lav","lv","Latvian" 96 | "lim","li","Limburgan; Limburger; Limburgish" 97 | "lin","ln","Lingala" 98 | "lit","lt","Lithuanian" 99 | "ltz","lb","Luxembourgish; Letzeburgesch" 100 | "lub","lu","Luba-Katanga" 101 | "lug","lg","Ganda" 102 | "mac","mk","Macedonian" 103 | "mah","mh","Marshallese" 104 | "mal","ml","Malayalam" 105 | "mao","mi","Maori" 106 | "mar","mr","Marathi" 107 | "may","ms","Malay" 108 | "mlg","mg","Malagasy" 109 | "mlt","mt","Maltese" 110 | "mon","mn","Mongolian" 111 | "nau","na","Nauru" 112 | "nav","nv","Navajo; Navaho" 113 | "nbl","nr","Ndebele, South; South Ndebele" 114 | "nde","nd","Ndebele, North; North Ndebele" 115 | "ndo","ng","Ndonga" 116 | "nep","ne","Nepali" 117 | "nno","nn","Norwegian Nynorsk; Nynorsk, Norwegian" 118 | "nob","nb","Bokmål, Norwegian; Norwegian Bokmål" 119 | "nor","no","Norwegian" 120 | "nya","ny","Chichewa; Chewa; Nyanja" 121 | "oci","oc","Occitan (post 1500)" 122 | "oji","oj","Ojibwa" 123 | "ori","or","Oriya" 124 | "orm","om","Oromo" 125 | "oss","os","Ossetian; Ossetic" 126 | "pan","pa","Panjabi; Punjabi" 127 | "per","fa","Persian" 128 | "pli","pi","Pali" 129 | "pol","pl","Polish" 130 | "por","pt","Portuguese" 131 | "pus","ps","Pushto; Pashto" 132 | "que","qu","Quechua" 133 | "roh","rm","Romansh" 134 | "rum","ro","Romanian; Moldavian; Moldovan" 135 | "run","rn","Rundi" 136 | "rus","ru","Russian" 137 | "sag","sg","Sango" 138 | "san","sa","Sanskrit" 139 | "sin","si","Sinhala; Sinhalese" 140 | "slo","sk","Slovak" 141 | "slv","sl","Slovenian" 142 | "sme","se","Northern Sami" 143 | "smo","sm","Samoan" 144 | "sna","sn","Shona" 145 | "snd","sd","Sindhi" 146 | "som","so","Somali" 147 | "sot","st","Sotho, Southern" 148 | "spa","es","Spanish; Castilian" 149 | "srd","sc","Sardinian" 150 | "srp","sr","Serbian" 151 | "ssw","ss","Swati" 152 | "sun","su","Sundanese" 153 | "swa","sw","Swahili" 154 | "swe","sv","Swedish" 155 | "tah","ty","Tahitian" 156 | "tam","ta","Tamil" 157 | "tat","tt","Tatar" 158 | "tel","te","Telugu" 159 | "tgk","tg","Tajik" 160 | "tgl","tl","Tagalog" 161 | "tha","th","Thai" 162 | "tib","bo","Tibetan" 163 | "tir","ti","Tigrinya" 164 | "ton","to","Tonga (Tonga Islands)" 165 | "tsn","tn","Tswana" 166 | "tso","ts","Tsonga" 167 | "tuk","tk","Turkmen" 168 | "tur","tr","Turkish" 169 | "twi","tw","Twi" 170 | "uig","ug","Uighur; Uyghur" 171 | "ukr","uk","Ukrainian" 172 | "urd","ur","Urdu" 173 | "uzb","uz","Uzbek" 174 | "ven","ve","Venda" 175 | "vie","vi","Vietnamese" 176 | "vol","vo","Volapük" 177 | "wel","cy","Welsh" 178 | "wln","wa","Walloon" 179 | "wol","wo","Wolof" 180 | "xho","xh","Xhosa" 181 | "yid","yi","Yiddish" 182 | "yor","yo","Yoruba" 183 | "zha","za","Zhuang; Chuang" 184 | "zul","zu","Zulu" 185 | -------------------------------------------------------------------------------- /internal/iso/languages.go: -------------------------------------------------------------------------------- 1 | // Code generated by gen.py from csv; DO NOT EDIT. 2 | package iso 3 | 4 | var LanguageAlpha2 = map[string]struct{}{ 5 | "aa": {}, 6 | "ab": {}, 7 | "af": {}, 8 | "ak": {}, 9 | "sq": {}, 10 | "am": {}, 11 | "ar": {}, 12 | "an": {}, 13 | "hy": {}, 14 | "as": {}, 15 | "av": {}, 16 | "ae": {}, 17 | "ay": {}, 18 | "az": {}, 19 | "ba": {}, 20 | "bm": {}, 21 | "eu": {}, 22 | "be": {}, 23 | "bn": {}, 24 | "bi": {}, 25 | "bs": {}, 26 | "br": {}, 27 | "bg": {}, 28 | "my": {}, 29 | "ca": {}, 30 | "ch": {}, 31 | "ce": {}, 32 | "zh": {}, 33 | "cu": {}, 34 | "cv": {}, 35 | "kw": {}, 36 | "co": {}, 37 | "cr": {}, 38 | "cs": {}, 39 | "da": {}, 40 | "dv": {}, 41 | "nl": {}, 42 | "dz": {}, 43 | "en": {}, 44 | "eo": {}, 45 | "et": {}, 46 | "ee": {}, 47 | "fo": {}, 48 | "fj": {}, 49 | "fi": {}, 50 | "fr": {}, 51 | "fy": {}, 52 | "ff": {}, 53 | "ka": {}, 54 | "de": {}, 55 | "gd": {}, 56 | "ga": {}, 57 | "gl": {}, 58 | "gv": {}, 59 | "el": {}, 60 | "gn": {}, 61 | "gu": {}, 62 | "ht": {}, 63 | "ha": {}, 64 | "he": {}, 65 | "hz": {}, 66 | "hi": {}, 67 | "ho": {}, 68 | "hr": {}, 69 | "hu": {}, 70 | "ig": {}, 71 | "is": {}, 72 | "io": {}, 73 | "ii": {}, 74 | "iu": {}, 75 | "ie": {}, 76 | "ia": {}, 77 | "id": {}, 78 | "ik": {}, 79 | "it": {}, 80 | "jv": {}, 81 | "ja": {}, 82 | "kl": {}, 83 | "kn": {}, 84 | "ks": {}, 85 | "kr": {}, 86 | "kk": {}, 87 | "km": {}, 88 | "ki": {}, 89 | "rw": {}, 90 | "ky": {}, 91 | "kv": {}, 92 | "kg": {}, 93 | "ko": {}, 94 | "kj": {}, 95 | "ku": {}, 96 | "lo": {}, 97 | "la": {}, 98 | "lv": {}, 99 | "li": {}, 100 | "ln": {}, 101 | "lt": {}, 102 | "lb": {}, 103 | "lu": {}, 104 | "lg": {}, 105 | "mk": {}, 106 | "mh": {}, 107 | "ml": {}, 108 | "mi": {}, 109 | "mr": {}, 110 | "ms": {}, 111 | "mg": {}, 112 | "mt": {}, 113 | "mn": {}, 114 | "na": {}, 115 | "nv": {}, 116 | "nr": {}, 117 | "nd": {}, 118 | "ng": {}, 119 | "ne": {}, 120 | "nn": {}, 121 | "nb": {}, 122 | "no": {}, 123 | "ny": {}, 124 | "oc": {}, 125 | "oj": {}, 126 | "or": {}, 127 | "om": {}, 128 | "os": {}, 129 | "pa": {}, 130 | "fa": {}, 131 | "pi": {}, 132 | "pl": {}, 133 | "pt": {}, 134 | "ps": {}, 135 | "qu": {}, 136 | "rm": {}, 137 | "ro": {}, 138 | "rn": {}, 139 | "ru": {}, 140 | "sg": {}, 141 | "sa": {}, 142 | "si": {}, 143 | "sk": {}, 144 | "sl": {}, 145 | "se": {}, 146 | "sm": {}, 147 | "sn": {}, 148 | "sd": {}, 149 | "so": {}, 150 | "st": {}, 151 | "es": {}, 152 | "sc": {}, 153 | "sr": {}, 154 | "ss": {}, 155 | "su": {}, 156 | "sw": {}, 157 | "sv": {}, 158 | "ty": {}, 159 | "ta": {}, 160 | "tt": {}, 161 | "te": {}, 162 | "tg": {}, 163 | "tl": {}, 164 | "th": {}, 165 | "bo": {}, 166 | "ti": {}, 167 | "to": {}, 168 | "tn": {}, 169 | "ts": {}, 170 | "tk": {}, 171 | "tr": {}, 172 | "tw": {}, 173 | "ug": {}, 174 | "uk": {}, 175 | "ur": {}, 176 | "uz": {}, 177 | "ve": {}, 178 | "vi": {}, 179 | "vo": {}, 180 | "cy": {}, 181 | "wa": {}, 182 | "wo": {}, 183 | "xh": {}, 184 | "yi": {}, 185 | "yo": {}, 186 | "za": {}, 187 | "zu": {}, 188 | } 189 | 190 | var LanguageAlpha3 = map[string]struct{}{ 191 | "aar": {}, 192 | "abk": {}, 193 | "afr": {}, 194 | "aka": {}, 195 | "alb": {}, 196 | "amh": {}, 197 | "ara": {}, 198 | "arg": {}, 199 | "arm": {}, 200 | "asm": {}, 201 | "ava": {}, 202 | "ave": {}, 203 | "aym": {}, 204 | "aze": {}, 205 | "bak": {}, 206 | "bam": {}, 207 | "baq": {}, 208 | "bel": {}, 209 | "ben": {}, 210 | "bis": {}, 211 | "bos": {}, 212 | "bre": {}, 213 | "bul": {}, 214 | "bur": {}, 215 | "cat": {}, 216 | "cha": {}, 217 | "che": {}, 218 | "chi": {}, 219 | "chu": {}, 220 | "chv": {}, 221 | "cor": {}, 222 | "cos": {}, 223 | "cre": {}, 224 | "cze": {}, 225 | "dan": {}, 226 | "div": {}, 227 | "dut": {}, 228 | "dzo": {}, 229 | "eng": {}, 230 | "epo": {}, 231 | "est": {}, 232 | "ewe": {}, 233 | "fao": {}, 234 | "fij": {}, 235 | "fin": {}, 236 | "fre": {}, 237 | "fry": {}, 238 | "ful": {}, 239 | "geo": {}, 240 | "ger": {}, 241 | "gla": {}, 242 | "gle": {}, 243 | "glg": {}, 244 | "glv": {}, 245 | "gre": {}, 246 | "grn": {}, 247 | "guj": {}, 248 | "hat": {}, 249 | "hau": {}, 250 | "heb": {}, 251 | "her": {}, 252 | "hin": {}, 253 | "hmo": {}, 254 | "hrv": {}, 255 | "hun": {}, 256 | "ibo": {}, 257 | "ice": {}, 258 | "ido": {}, 259 | "iii": {}, 260 | "iku": {}, 261 | "ile": {}, 262 | "ina": {}, 263 | "ind": {}, 264 | "ipk": {}, 265 | "ita": {}, 266 | "jav": {}, 267 | "jpn": {}, 268 | "kal": {}, 269 | "kan": {}, 270 | "kas": {}, 271 | "kau": {}, 272 | "kaz": {}, 273 | "khm": {}, 274 | "kik": {}, 275 | "kin": {}, 276 | "kir": {}, 277 | "kom": {}, 278 | "kon": {}, 279 | "kor": {}, 280 | "kua": {}, 281 | "kur": {}, 282 | "lao": {}, 283 | "lat": {}, 284 | "lav": {}, 285 | "lim": {}, 286 | "lin": {}, 287 | "lit": {}, 288 | "ltz": {}, 289 | "lub": {}, 290 | "lug": {}, 291 | "mac": {}, 292 | "mah": {}, 293 | "mal": {}, 294 | "mao": {}, 295 | "mar": {}, 296 | "may": {}, 297 | "mlg": {}, 298 | "mlt": {}, 299 | "mon": {}, 300 | "nau": {}, 301 | "nav": {}, 302 | "nbl": {}, 303 | "nde": {}, 304 | "ndo": {}, 305 | "nep": {}, 306 | "nno": {}, 307 | "nob": {}, 308 | "nor": {}, 309 | "nya": {}, 310 | "oci": {}, 311 | "oji": {}, 312 | "ori": {}, 313 | "orm": {}, 314 | "oss": {}, 315 | "pan": {}, 316 | "per": {}, 317 | "pli": {}, 318 | "pol": {}, 319 | "por": {}, 320 | "pus": {}, 321 | "que": {}, 322 | "roh": {}, 323 | "rum": {}, 324 | "run": {}, 325 | "rus": {}, 326 | "sag": {}, 327 | "san": {}, 328 | "sin": {}, 329 | "slo": {}, 330 | "slv": {}, 331 | "sme": {}, 332 | "smo": {}, 333 | "sna": {}, 334 | "snd": {}, 335 | "som": {}, 336 | "sot": {}, 337 | "spa": {}, 338 | "srd": {}, 339 | "srp": {}, 340 | "ssw": {}, 341 | "sun": {}, 342 | "swa": {}, 343 | "swe": {}, 344 | "tah": {}, 345 | "tam": {}, 346 | "tat": {}, 347 | "tel": {}, 348 | "tgk": {}, 349 | "tgl": {}, 350 | "tha": {}, 351 | "tib": {}, 352 | "tir": {}, 353 | "ton": {}, 354 | "tsn": {}, 355 | "tso": {}, 356 | "tuk": {}, 357 | "tur": {}, 358 | "twi": {}, 359 | "uig": {}, 360 | "ukr": {}, 361 | "urd": {}, 362 | "uzb": {}, 363 | "ven": {}, 364 | "vie": {}, 365 | "vol": {}, 366 | "wel": {}, 367 | "wln": {}, 368 | "wol": {}, 369 | "xho": {}, 370 | "yid": {}, 371 | "yor": {}, 372 | "zha": {}, 373 | "zul": {}, 374 | } 375 | -------------------------------------------------------------------------------- /internal/reflectwalk/walk.go: -------------------------------------------------------------------------------- 1 | package reflectwalk 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | "strconv" 7 | ) 8 | 9 | // FieldVisitor defines a function signature for the callback. 10 | // It receives the path to the field and its value. 11 | type FieldVisitor func(path string, value reflect.Value) error 12 | 13 | // WalkFields traverses all fields in the given value, calling visitor for each field. 14 | func WalkFields(data any, visitor FieldVisitor) error { 15 | // Track visited pointers to prevent infinite recursion on cycles. 16 | visited := make(map[uintptr]bool) 17 | 18 | return walkRecursive("", reflect.ValueOf(data), visitor, visited) 19 | } 20 | 21 | func walkRecursive( 22 | path string, 23 | v reflect.Value, 24 | visitor FieldVisitor, 25 | visited map[uintptr]bool, 26 | ) error { 27 | // If we have an invalid (zero) reflect.Value, just fire the visitor. 28 | if !v.IsValid() { 29 | return visitor(path, v) 30 | } 31 | 32 | // Call the visitor on the current value first. 33 | if err := visitor(path, v); err != nil { 34 | return err 35 | } 36 | 37 | switch v.Kind() { 38 | case reflect.Ptr: 39 | return walkPtr(path, v, visitor, visited) 40 | 41 | case reflect.Interface: 42 | return walkInterface(path, v, visitor, visited) 43 | 44 | case reflect.Struct: 45 | return walkStruct(path, v, visitor, visited) 46 | 47 | case reflect.Array, reflect.Slice: 48 | return walkSlice(path, v, visitor, visited) 49 | 50 | case reflect.Map: 51 | return walkMap(path, v, visitor, visited) 52 | 53 | default: 54 | return nil 55 | } 56 | } 57 | 58 | func walkPtr(path string, v reflect.Value, visitor FieldVisitor, visited map[uintptr]bool) error { 59 | // Check for nil pointer and visited pointer cycle first. 60 | if v.IsNil() { 61 | return nil 62 | } 63 | 64 | ptr := v.Pointer() 65 | if visited[ptr] { 66 | return nil 67 | } 68 | 69 | visited[ptr] = true 70 | 71 | return walkRecursive(path, v.Elem(), visitor, visited) 72 | } 73 | 74 | func walkInterface( 75 | path string, 76 | v reflect.Value, 77 | visitor FieldVisitor, 78 | visited map[uintptr]bool, 79 | ) error { 80 | // If interface is nil, nothing to do. 81 | if v.IsNil() { 82 | return nil 83 | } 84 | 85 | return walkRecursive(path, v.Elem(), visitor, visited) 86 | } 87 | 88 | func walkStruct( 89 | path string, 90 | v reflect.Value, 91 | visitor FieldVisitor, 92 | visited map[uintptr]bool, 93 | ) error { 94 | t := v.Type() 95 | 96 | for i := range v.NumField() { 97 | fieldVal := v.Field(i) 98 | 99 | // Skip unexported fields. 100 | if !fieldVal.CanInterface() { 101 | continue 102 | } 103 | 104 | fieldName := t.Field(i).Name 105 | fieldPath := path + "." + fieldName 106 | 107 | if err := walkRecursive(fieldPath, fieldVal, visitor, visited); err != nil { 108 | return err 109 | } 110 | } 111 | 112 | return nil 113 | } 114 | 115 | func walkSlice(path string, v reflect.Value, visitor FieldVisitor, visited map[uintptr]bool) error { 116 | // After visiting the slice itself, skip if it's nil. 117 | if v.Kind() == reflect.Slice && v.IsNil() { 118 | return nil 119 | } 120 | 121 | for i := range v.Len() { 122 | indexPath := path + "[" + strconv.Itoa(i) + "]" 123 | if err := walkRecursive(indexPath, v.Index(i), visitor, visited); err != nil { 124 | return err 125 | } 126 | } 127 | 128 | return nil 129 | } 130 | 131 | func walkMap(path string, v reflect.Value, visitor FieldVisitor, visited map[uintptr]bool) error { 132 | // After visiting the map itself, skip if it's nil. 133 | if v.IsNil() { 134 | return nil 135 | } 136 | 137 | keys := v.MapKeys() 138 | for _, key := range keys { 139 | valuePath := path + "[" + formatStr(key) + "]" 140 | val := v.MapIndex(key) 141 | 142 | if err := walkRecursive(valuePath, val, visitor, visited); err != nil { 143 | return err 144 | } 145 | } 146 | 147 | return nil 148 | } 149 | 150 | func formatStr(v reflect.Value) string { 151 | switch v.Kind() { 152 | case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: 153 | return strconv.FormatInt(v.Int(), 10) 154 | 155 | case reflect.Uint, 156 | reflect.Uint8, 157 | reflect.Uint16, 158 | reflect.Uint32, 159 | reflect.Uint64, 160 | reflect.Uintptr: 161 | return strconv.FormatUint(v.Uint(), 10) 162 | 163 | case reflect.Bool: 164 | if v.Bool() { 165 | return "true" 166 | } 167 | 168 | return "false" 169 | 170 | case reflect.Float32, reflect.Float64: 171 | return strconv.FormatFloat(v.Float(), 'g', -1, 64) 172 | 173 | case reflect.String: 174 | return v.String() 175 | 176 | default: 177 | // Fallback for complex or otherwise unsupported key kinds. 178 | return fmt.Sprint(v.Interface()) 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /internal/reflectwalk/walk_test.go: -------------------------------------------------------------------------------- 1 | package reflectwalk 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | ) 7 | 8 | func TestWalkFields(t *testing.T) { 9 | type Foo struct{ Bar string } 10 | 11 | type Mock struct { 12 | Foo 13 | 14 | Name string 15 | Map map[string][]int 16 | 17 | Anon struct { 18 | unexported bool 19 | Exported uint8 20 | } 21 | 22 | Ptr *Foo 23 | Ptr2 *Foo 24 | } 25 | 26 | mock := Mock{ 27 | Foo: Foo{Bar: "x"}, 28 | Name: "x", 29 | Map: map[string][]int{ 30 | "key": {1, 2, 3}, 31 | "key2": nil, 32 | }, 33 | Anon: struct { 34 | unexported bool 35 | Exported uint8 36 | }{ 37 | unexported: true, 38 | Exported: 1, 39 | }, 40 | Ptr: &Foo{ 41 | Bar: "x", 42 | }, 43 | Ptr2: nil, 44 | } 45 | 46 | want := map[string]any{ 47 | "": mock, 48 | ".Anon": mock.Anon, 49 | ".Anon.Exported": mock.Anon.Exported, 50 | ".Foo": mock.Foo, 51 | ".Foo.Bar": mock.Foo.Bar, 52 | ".Map": mock.Map, 53 | ".Map[key2]": mock.Map["key2"], 54 | ".Map[key]": mock.Map["key"], 55 | ".Map[key][0]": mock.Map["key"][0], 56 | ".Map[key][1]": mock.Map["key"][1], 57 | ".Map[key][2]": mock.Map["key"][2], 58 | ".Name": mock.Name, 59 | ".Ptr": *mock.Ptr, 60 | ".Ptr.Bar": mock.Ptr.Bar, 61 | ".Ptr2": mock.Ptr2, 62 | } 63 | 64 | visited := make(map[string]any) 65 | 66 | err := WalkFields(mock, func(path string, value reflect.Value) error { 67 | visited[path] = value.Interface() 68 | 69 | return nil 70 | }) 71 | if err != nil { 72 | t.Errorf("unexpected error: %v", err) 73 | } 74 | 75 | if !reflect.DeepEqual(want, visited) { 76 | t.Errorf("not equal:\nwant %#+v\ngot %#+v", want, visited) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /internal/testutil/util.go: -------------------------------------------------------------------------------- 1 | package testutil 2 | 3 | import "reflect" 4 | 5 | type Handle interface { 6 | Helper() 7 | Fatalf(format string, args ...any) 8 | } 9 | 10 | func Equal[T comparable](t Handle, want, actual T) { 11 | t.Helper() 12 | 13 | if want != actual { 14 | t.Fatalf("not equal:\nwant %+v\ngot %+v", want, actual) 15 | } 16 | } 17 | 18 | func DeepEqual(t Handle, want, actual any) { 19 | t.Helper() 20 | 21 | if !reflect.DeepEqual(want, actual) { 22 | t.Fatalf("not equal:\nwant %+v\ngot %+v", want, actual) 23 | } 24 | } 25 | 26 | func NoError(t Handle, err error) { 27 | t.Helper() 28 | 29 | if err != nil { 30 | t.Fatalf("unexpected error: %v", err) 31 | } 32 | } 33 | 34 | func Error(t Handle, err error) { 35 | t.Helper() 36 | 37 | if err == nil { 38 | t.Fatalf("error is nil") 39 | } 40 | } 41 | 42 | func Panic(t Handle, f func()) { 43 | t.Helper() 44 | 45 | defer func() { 46 | if r := recover(); r == nil { 47 | t.Fatalf("did not panic") 48 | } 49 | }() 50 | 51 | f() 52 | } 53 | 54 | func NoPanic(t Handle, f func()) { 55 | t.Helper() 56 | 57 | defer func() { 58 | if r := recover(); r != nil { 59 | t.Fatalf("unexpected panic") 60 | } 61 | }() 62 | 63 | f() 64 | } 65 | -------------------------------------------------------------------------------- /internal/typeconv/typeconv.go: -------------------------------------------------------------------------------- 1 | package typeconv 2 | 3 | import ( 4 | "go/types" 5 | "strings" 6 | "unicode" 7 | 8 | "github.com/dave/jennifer/jen" 9 | ) 10 | 11 | // NOTE: generated with AI. Validate it 12 | 13 | type TypeConverter struct { 14 | imports map[string]string 15 | } 16 | 17 | func NewTypeConverter() TypeConverter { 18 | return TypeConverter{imports: make(map[string]string)} 19 | } 20 | 21 | func (c *TypeConverter) AddImports(f *jen.File) { 22 | for path, name := range c.imports { 23 | f.ImportName(path, name) 24 | } 25 | } 26 | 27 | func (c *TypeConverter) ConvertType(t types.Type) jen.Code { 28 | switch t := t.(type) { 29 | case *types.Named: 30 | return c.convertTypeNamed(t) 31 | 32 | case *types.Alias: 33 | return c.ConvertType(t.Rhs()) 34 | 35 | case *types.Basic: 36 | return jen.Id(t.Name()) 37 | 38 | case *types.Pointer: 39 | return jen.Op("*").Add(c.ConvertType(t.Elem())) 40 | 41 | case *types.Slice: 42 | return jen.Index().Add(c.ConvertType(t.Elem())) 43 | 44 | case *types.Map: 45 | return jen.Map(c.ConvertType(t.Key())).Add(c.ConvertType(t.Elem())) 46 | 47 | case *types.Struct: 48 | return c.convertTypeStruct(t) 49 | 50 | default: 51 | // Fallback for any other types 52 | return jen.Id(t.String()) 53 | } 54 | } 55 | 56 | func (c *TypeConverter) convertTypeStruct(t *types.Struct) jen.Code { 57 | fields := make([]jen.Code, 0, t.NumFields()) 58 | 59 | for i := range t.NumFields() { 60 | field := t.Field(i) 61 | tag := t.Tag(i) 62 | fieldCode := jen.Id(field.Name()).Add(c.ConvertType(field.Type())) 63 | 64 | if tag != "" { 65 | tagMap := parseStructTags(tag) 66 | fieldCode = fieldCode.Tag(tagMap) 67 | } 68 | 69 | fields = append(fields, fieldCode) 70 | } 71 | 72 | return jen.Struct(fields...) 73 | } 74 | 75 | func (c *TypeConverter) convertTypeNamed(t *types.Named) jen.Code { 76 | pkg := t.Obj().Pkg() 77 | 78 | if pkg != nil && pkg.Path() != "" { 79 | // Record the import 80 | c.imports[pkg.Path()] = pkg.Name() 81 | 82 | // Create a qualified reference 83 | qual := jen.Qual(pkg.Path(), t.Obj().Name()) 84 | 85 | // Handle type arguments if present 86 | typeArgs := t.TypeArgs() 87 | 88 | if typeArgs != nil && typeArgs.Len() > 0 { 89 | var args []jen.Code 90 | 91 | for i := range typeArgs.Len() { 92 | args = append(args, c.ConvertType(typeArgs.At(i))) 93 | } 94 | 95 | return qual.Types(args...) 96 | } 97 | 98 | return qual 99 | } 100 | 101 | return jen.Id(t.Obj().Name()) 102 | } 103 | 104 | // parseStructTags parses a raw struct tag string into a map[string]string. 105 | func parseStructTags(tag string) map[string]string { 106 | tags := make(map[string]string) 107 | 108 | // Simple state machine to parse tags 109 | for tag != "" { 110 | name, value, newTag, ok := parseStructTag(tag) 111 | if ok { 112 | tags[name] = value 113 | } 114 | 115 | tag = newTag 116 | } 117 | 118 | return tags 119 | } 120 | 121 | //nolint:nonamedreturns 122 | func parseStructTag(tag string) (name, value, rest string, ok bool) { 123 | tag = strings.TrimLeftFunc(tag, unicode.IsSpace) 124 | 125 | if tag == "" { 126 | return "", "", "", false 127 | } 128 | 129 | // Scan to colon 130 | i := 0 131 | for i < len(tag) && tag[i] != ':' { 132 | i++ 133 | } 134 | 135 | if i >= len(tag) { 136 | return "", "", "", false 137 | } 138 | 139 | name = tag[:i] 140 | tag = tag[i+1:] 141 | 142 | // Scan to closing quote, handling escaped quotes 143 | if tag[0] != '"' { 144 | return "", "", tag, false 145 | } 146 | 147 | i = 1 148 | for i < len(tag) { 149 | if tag[i] == '"' && tag[i-1] != '\\' { 150 | break 151 | } 152 | 153 | i++ 154 | } 155 | 156 | if i >= len(tag) { 157 | return "", "", tag, false 158 | } 159 | 160 | value = tag[1:i] 161 | tag = tag[i+1:] 162 | 163 | return name, value, tag, true 164 | } 165 | -------------------------------------------------------------------------------- /internal/typeconv/typeconv_test.go: -------------------------------------------------------------------------------- 1 | package typeconv 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/metafates/schema/internal/testutil" 7 | ) 8 | 9 | func TestParseStructTag(t *testing.T) { 10 | tests := []struct { 11 | name string 12 | input string 13 | want map[string]string 14 | }{ 15 | { 16 | name: "empty string", 17 | input: "", 18 | want: map[string]string{}, 19 | }, 20 | { 21 | name: "single valid tag", 22 | input: `json:"name"`, 23 | want: map[string]string{"json": "name"}, 24 | }, 25 | { 26 | name: "multiple tags with spaces", 27 | input: ` json:"name" xml:"id" `, 28 | want: map[string]string{"json": "name", "xml": "id"}, 29 | }, 30 | { 31 | name: "escaped quote in value", 32 | input: `json:"na\"me"`, 33 | want: map[string]string{"json": `na\"me`}, 34 | }, 35 | { 36 | name: "unescaped quote in value", 37 | input: `json:"na"me"`, 38 | want: map[string]string{"json": "na"}, 39 | }, 40 | { 41 | name: "missing closing quote", 42 | input: `json:"name`, 43 | want: map[string]string{}, 44 | }, 45 | { 46 | name: "key with no colon", 47 | input: `json`, 48 | want: map[string]string{}, 49 | }, 50 | { 51 | name: "key with colon but no quote", 52 | input: `json:name`, 53 | want: map[string]string{}, 54 | }, 55 | { 56 | name: "empty key", 57 | input: `:"value"`, 58 | want: map[string]string{"": "value"}, 59 | }, 60 | { 61 | name: "duplicate keys", 62 | input: `json:"a" json:"b"`, 63 | want: map[string]string{"json": "b"}, 64 | }, 65 | { 66 | name: "key with trailing spaces and valid value", 67 | input: ` json :"name" `, 68 | want: map[string]string{"json ": "name"}, 69 | }, 70 | { 71 | name: "value with backslashes", 72 | input: `json:"a\\b"`, 73 | want: map[string]string{"json": `a\\b`}, 74 | }, 75 | { 76 | name: "empty value", 77 | input: `json:""`, 78 | want: map[string]string{"json": ""}, 79 | }, 80 | } 81 | 82 | for _, tt := range tests { 83 | t.Run(tt.name, func(t *testing.T) { 84 | actual := parseStructTags(tt.input) 85 | 86 | testutil.DeepEqual(t, tt.want, actual) 87 | }) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /internal/uuid/uuid.go: -------------------------------------------------------------------------------- 1 | package uuid 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "strings" 7 | ) 8 | 9 | // xvalues returns the value of a byte as a hexadecimal digit or 255. 10 | var xvalues = [256]byte{ 11 | 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 12 | 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 13 | 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 14 | 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 255, 255, 255, 255, 255, 255, 15 | 255, 10, 11, 12, 13, 14, 15, 255, 255, 255, 255, 255, 255, 255, 255, 255, 16 | 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 17 | 255, 10, 11, 12, 13, 14, 15, 255, 255, 255, 255, 255, 255, 255, 255, 255, 18 | 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 19 | 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 20 | 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 21 | 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 22 | 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 23 | 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 24 | 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 25 | 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 26 | 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 27 | } 28 | 29 | // xtob states whether hex characters x1 and x2 could be converted into a byte. 30 | func xtob(x1, x2 byte) bool { 31 | b1 := xvalues[x1] 32 | b2 := xvalues[x2] 33 | 34 | return b1 != 255 && b2 != 255 35 | } 36 | 37 | // Validate if given string is a valid UUID. 38 | // 39 | // https://github.com/google/uuid/blob/0f11ee6918f41a04c201eceeadf612a377bc7fbc/uuid.go#L195 40 | // 41 | //nolint:cyclop 42 | func Validate(s string) error { 43 | const standardLen = 36 44 | 45 | switch len(s) { 46 | // Standard UUID format 47 | case standardLen: 48 | 49 | // UUID with "urn:uuid:" prefix 50 | case standardLen + 9: 51 | if !strings.EqualFold(s[:9], "urn:uuid:") { 52 | return fmt.Errorf("invalid urn prefix: %q", s[:9]) 53 | } 54 | 55 | s = s[9:] 56 | 57 | // UUID enclosed in braces 58 | case standardLen + 2: 59 | if s[0] != '{' || s[len(s)-1] != '}' { 60 | return errors.New("invalid bracketed UUID format") 61 | } 62 | 63 | s = s[1 : len(s)-1] 64 | 65 | // UUID without hyphens 66 | case standardLen - 4: 67 | for i := 0; i < len(s); i += 2 { 68 | if !xtob(s[i], s[i+1]) { 69 | return errors.New("invalid UUID format") 70 | } 71 | } 72 | 73 | default: 74 | return fmt.Errorf("invalid UUID length: %d", len(s)) 75 | } 76 | 77 | // Check for standard UUID format 78 | if len(s) == standardLen { 79 | if s[8] != '-' || s[13] != '-' || s[18] != '-' || s[23] != '-' { 80 | return errors.New("invalid UUID format") 81 | } 82 | 83 | for _, x := range []int{0, 2, 4, 6, 9, 11, 14, 16, 19, 21, 24, 26, 28, 30, 32, 34} { 84 | if !xtob(s[x], s[x+1]) { 85 | return errors.New("invalid UUID format") 86 | } 87 | } 88 | } 89 | 90 | return nil 91 | } 92 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | check: fmt test run-examples lint 2 | 3 | # Run all tests including examples 4 | test: generate 5 | go test ./... 6 | 7 | run-examples: 8 | go run ./examples/tour 9 | go run ./examples/codegen 10 | go run ./examples/parse 11 | go run ./examples/parse-grpc 12 | 13 | # Run benchmarks. May take a long time, use bench-short to avoid. 14 | bench: generate 15 | go test ./... -bench=. -benchmem 16 | 17 | # Run short benchmarks. 18 | bench-short: generate 19 | go test ./... -bench=. -benchmem -short 20 | 21 | # Generate test coverage 22 | coverage: 23 | go test -coverprofile=coverage.html ./... 24 | go tool cover -html=coverage.html 25 | 26 | # Generate code 27 | generate: install-schemagen 28 | go generate ./... 29 | 30 | # Install schemagen 31 | install-schemagen: 32 | go install ./cmd/schemagen 33 | 34 | # Update ISO datasets 35 | update-iso: && generate 36 | # country codes 37 | curl -f -L -o ./internal/iso/countries.csv https://raw.githubusercontent.com/datasets/country-codes/refs/heads/main/data/country-codes.csv 38 | 39 | # currencies 40 | curl -f -L -o ./internal/iso/currencies.csv https://raw.githubusercontent.com/datasets/currency-codes/refs/heads/main/data/codes-all.csv 41 | 42 | # languages 43 | curl -f -L -o ./internal/iso/languages.csv https://raw.githubusercontent.com/datasets/language-codes/refs/heads/main/data/language-codes-3b2.csv 44 | 45 | # format source code 46 | fmt: 47 | golangci-lint fmt 48 | 49 | # lint source code 50 | lint: 51 | golangci-lint run --tests=false 52 | 53 | # Open documentation 54 | doc: 55 | go run golang.org/x/pkgsite/cmd/pkgsite@latest -open 56 | -------------------------------------------------------------------------------- /optional/binary.go: -------------------------------------------------------------------------------- 1 | package optional 2 | 3 | import ( 4 | "encoding" 5 | 6 | "github.com/metafates/schema/validate" 7 | ) 8 | 9 | var _ interface { 10 | encoding.BinaryUnmarshaler 11 | encoding.BinaryMarshaler 12 | } = (*Custom[any, validate.Validator[any]])(nil) 13 | 14 | // UnmarshalBinary implements the [encoding.BinaryUnmarshaler] interface. 15 | func (c *Custom[T, V]) UnmarshalBinary(data []byte) error { 16 | return c.GobDecode(data) 17 | } 18 | 19 | // MarshalBinary implements the [encoding.BinaryMarshaler] interface. 20 | func (c Custom[T, V]) MarshalBinary() ([]byte, error) { 21 | return c.GobEncode() 22 | } 23 | -------------------------------------------------------------------------------- /optional/custom.go: -------------------------------------------------------------------------------- 1 | // Package optional provides types whose values may be either empty (null) or be present and pass validation. 2 | // 3 | // Optional types support the following encoding/decoding formats: 4 | // - json 5 | // - sql 6 | // - text 7 | // - binary 8 | // - gob 9 | package optional 10 | 11 | import ( 12 | "reflect" 13 | 14 | "github.com/metafates/schema/parse" 15 | "github.com/metafates/schema/validate" 16 | ) 17 | 18 | // Custom optional type. 19 | // When given non-null value it errors if validation fails. 20 | type Custom[T any, V validate.Validator[T]] struct { 21 | value T 22 | hasValue bool 23 | validated bool 24 | } 25 | 26 | // TypeValidate implements the [validate.TypeValidateable] interface. 27 | // You should not call this function directly. 28 | func (c *Custom[T, V]) TypeValidate() error { 29 | if !c.hasValue { 30 | return nil 31 | } 32 | 33 | if err := (*new(V)).Validate(c.value); err != nil { 34 | return validate.ValidationError{Inner: err} 35 | } 36 | 37 | // validate nested types recursively 38 | if err := validate.Validate(&c.value); err != nil { 39 | return err 40 | } 41 | 42 | c.validated = true 43 | 44 | return nil 45 | } 46 | 47 | // HasValue returns the presence of the contained value. 48 | func (c Custom[T, V]) HasValue() bool { return c.hasValue } 49 | 50 | // Get returns the contained value and a boolean stating its presence. 51 | // True if value exists, false otherwise. 52 | // 53 | // Panics if value was not validated yet. 54 | // See also [Custom.GetPtr]. 55 | func (c Custom[T, V]) Get() (T, bool) { 56 | if c.hasValue && !c.validated { 57 | panic("called Get() on non-empty unvalidated value") 58 | } 59 | 60 | return c.value, c.hasValue 61 | } 62 | 63 | // Get returns the pointer to the contained value. 64 | // Non-nil if value exists, nil otherwise. 65 | // Pointed value is a shallow copy. 66 | // 67 | // Panics if value was not validated yet. 68 | // See also [Custom.Get]. 69 | func (c Custom[T, V]) GetPtr() *T { 70 | if c.hasValue && !c.validated { 71 | panic("called GetPtr() on non-empty unvalidated value") 72 | } 73 | 74 | var value *T 75 | 76 | if c.hasValue { 77 | valueCopy := c.value 78 | value = &valueCopy 79 | } 80 | 81 | return value 82 | } 83 | 84 | // Must returns the contained value and panics if it does not have one. 85 | // You can check for its presence using [Custom.HasValue] or use a more safe alternative [Custom.Get]. 86 | func (c Custom[T, V]) Must() T { 87 | if !c.hasValue { 88 | panic("called must on empty optional") 89 | } 90 | 91 | value, _ := c.Get() 92 | 93 | return value 94 | } 95 | 96 | // Parse checks if given value is valid. 97 | // If it is, a value is used to initialize this type. 98 | // Value is converted to the target type T, if possible. If not - [parse.UnconvertableTypeError] is returned. 99 | // It is allowed to pass convertable type wrapped in optional type. 100 | // 101 | // Parsed type is validated, therefore it is safe to call [Custom.Get] afterwards. 102 | // 103 | // Passing nil results a valid empty instance. 104 | func (c *Custom[T, V]) Parse(value any) error { 105 | if value == nil { 106 | *c = Custom[T, V]{} 107 | 108 | return nil 109 | } 110 | 111 | rValue := reflect.ValueOf(value) 112 | 113 | if rValue.Kind() == reflect.Pointer && rValue.IsNil() { 114 | *c = Custom[T, V]{} 115 | 116 | return nil 117 | } 118 | 119 | if _, ok := value.(interface{ isOptional() }); ok { 120 | // NOTE: ensure this method name is in sync with [Custom.Get] 121 | res := rValue.MethodByName("Get").Call(nil) 122 | v, ok := res[0], res[1].Bool() 123 | 124 | if !ok { 125 | *c = Custom[T, V]{} 126 | 127 | return nil 128 | } 129 | 130 | rValue = v 131 | } 132 | 133 | v, err := convert[T](rValue) 134 | if err != nil { 135 | return parse.ParseError{Inner: err} 136 | } 137 | 138 | aux := Custom[T, V]{ 139 | hasValue: true, 140 | value: v, 141 | } 142 | 143 | if err := aux.TypeValidate(); err != nil { 144 | return err 145 | } 146 | 147 | *c = aux 148 | 149 | return nil 150 | } 151 | 152 | func (c *Custom[T, V]) MustParse(value any) { 153 | if err := c.Parse(value); err != nil { 154 | panic("MustParse failed") 155 | } 156 | } 157 | 158 | func (Custom[T, V]) isOptional() {} 159 | 160 | func convert[T any](v reflect.Value) (T, error) { 161 | tType := reflect.TypeFor[T]() 162 | 163 | original := v 164 | 165 | if v.Kind() == reflect.Pointer { 166 | v = v.Elem() 167 | } 168 | 169 | if v.CanConvert(tType) { 170 | //nolint:forcetypeassert // checked already by CanConvert 171 | return v.Convert(tType).Interface().(T), nil 172 | } 173 | 174 | return *new(T), parse.UnconvertableTypeError{ 175 | Target: tType.String(), 176 | Original: original.Type().String(), 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /optional/gob.go: -------------------------------------------------------------------------------- 1 | package optional 2 | 3 | import ( 4 | "bytes" 5 | "encoding/gob" 6 | "errors" 7 | 8 | "github.com/metafates/schema/validate" 9 | ) 10 | 11 | var _ interface { 12 | gob.GobDecoder 13 | gob.GobEncoder 14 | } = (*Custom[any, validate.Validator[any]])(nil) 15 | 16 | // GobDecode implements the [gob.GobDecoder] interface. 17 | func (c *Custom[T, V]) GobDecode(data []byte) error { 18 | if len(data) == 0 { 19 | return errors.New("GobDecode: no data") 20 | } 21 | 22 | if data[0] == 0 { 23 | *c = Custom[T, V]{} 24 | 25 | return nil 26 | } 27 | 28 | buf := bytes.NewBuffer(data[1:]) 29 | dec := gob.NewDecoder(buf) 30 | 31 | var value T 32 | 33 | if err := dec.Decode(&value); err != nil { 34 | return err 35 | } 36 | 37 | *c = Custom[T, V]{value: value, hasValue: true} 38 | 39 | return nil 40 | } 41 | 42 | // GobEncode implements the [gob.GobEncoder] interface. 43 | func (c Custom[T, V]) GobEncode() ([]byte, error) { 44 | if !c.hasValue { 45 | return []byte{0}, nil 46 | } 47 | 48 | var buf bytes.Buffer 49 | 50 | buf.WriteByte(1) 51 | 52 | enc := gob.NewEncoder(&buf) 53 | if err := enc.Encode(c.Must()); err != nil { 54 | return nil, err 55 | } 56 | 57 | return buf.Bytes(), nil 58 | } 59 | -------------------------------------------------------------------------------- /optional/json.go: -------------------------------------------------------------------------------- 1 | package optional 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/metafates/schema/validate" 7 | ) 8 | 9 | var _ interface { 10 | json.Unmarshaler 11 | json.Marshaler 12 | } = (*Custom[any, validate.Validator[any]])(nil) 13 | 14 | // UnmarshalJSON implements the [json.Unmarshaler] interface. 15 | func (c *Custom[T, V]) UnmarshalJSON(data []byte) error { 16 | var value *T 17 | 18 | if err := json.Unmarshal(data, &value); err != nil { 19 | return err 20 | } 21 | 22 | // validated status will reset here 23 | if value == nil { 24 | *c = Custom[T, V]{} 25 | 26 | return nil 27 | } 28 | 29 | *c = Custom[T, V]{value: *value, hasValue: true} 30 | 31 | return nil 32 | } 33 | 34 | // MarshalJSON implements the [json.Marshaler] interface. 35 | func (c Custom[T, V]) MarshalJSON() ([]byte, error) { 36 | if c.hasValue { 37 | return json.Marshal(c.Must()) 38 | } 39 | 40 | return []byte("null"), nil 41 | } 42 | -------------------------------------------------------------------------------- /optional/optional.go: -------------------------------------------------------------------------------- 1 | // Code generated by validators.py; DO NOT EDIT. 2 | 3 | package optional 4 | 5 | import ( 6 | "github.com/metafates/schema/constraint" 7 | "github.com/metafates/schema/validate" 8 | "github.com/metafates/schema/validate/charset" 9 | ) 10 | 11 | // Any accepts any value of T. 12 | type Any[T any] = Custom[T, validate.Any[T]] 13 | 14 | // Zero accepts all zero values. 15 | // 16 | // The zero value is: 17 | // - 0 for numeric types, 18 | // - false for the boolean type, and 19 | // - "" (the empty string) for strings. 20 | // 21 | // See [NonZero]. 22 | type Zero[T comparable] = Custom[T, validate.Zero[T]] 23 | 24 | // NonZero accepts all non-zero values. 25 | // 26 | // The zero value is: 27 | // - 0 for numeric types, 28 | // - false for the boolean type, and 29 | // - "" (the empty string) for strings. 30 | // 31 | // See [Zero]. 32 | type NonZero[T comparable] = Custom[T, validate.NonZero[T]] 33 | 34 | // Positive accepts all positive real numbers excluding zero. 35 | // 36 | // See [Positive0] for zero including variant. 37 | type Positive[T constraint.Real] = Custom[T, validate.Positive[T]] 38 | 39 | // Negative accepts all negative real numbers excluding zero. 40 | // 41 | // See [Negative0] for zero including variant. 42 | type Negative[T constraint.Real] = Custom[T, validate.Negative[T]] 43 | 44 | // Positive0 accepts all positive real numbers including zero. 45 | // 46 | // See [Positive] for zero excluding variant. 47 | type Positive0[T constraint.Real] = Custom[T, validate.Positive0[T]] 48 | 49 | // Negative0 accepts all negative real numbers including zero. 50 | // 51 | // See [Negative] for zero excluding variant. 52 | type Negative0[T constraint.Real] = Custom[T, validate.Negative0[T]] 53 | 54 | // Even accepts integers divisible by two. 55 | type Even[T constraint.Integer] = Custom[T, validate.Even[T]] 56 | 57 | // Odd accepts integers not divisible by two. 58 | type Odd[T constraint.Integer] = Custom[T, validate.Odd[T]] 59 | 60 | // Email accepts a single RFC 5322 address, e.g. "Barry Gibbs ". 61 | type Email[T constraint.Text] = Custom[T, validate.Email[T]] 62 | 63 | // URL accepts a single url. 64 | // The url may be relative (a path, without a host) or absolute (starting with a scheme). 65 | // 66 | // See also [HTTPURL]. 67 | type URL[T constraint.Text] = Custom[T, validate.URL[T]] 68 | 69 | // HTTPURL accepts a single http(s) url. 70 | // 71 | // See also [URL]. 72 | type HTTPURL[T constraint.Text] = Custom[T, validate.HTTPURL[T]] 73 | 74 | // IP accepts an IP address. 75 | // The address can be in dotted decimal ("192.0.2.1"), 76 | // IPv6 ("2001:db8::68"), or IPv6 with a scoped addressing zone ("fe80::1cc0:3e8c:119f:c2e1%ens18"). 77 | type IP[T constraint.Text] = Custom[T, validate.IP[T]] 78 | 79 | // IPV4 accepts an IP V4 address (e.g. "192.0.2.1"). 80 | type IPV4[T constraint.Text] = Custom[T, validate.IPV4[T]] 81 | 82 | // IPV6 accepts an IP V6 address, including IPv4-mapped IPv6 addresses. 83 | // The address can be regular IPv6 ("2001:db8::68"), or IPv6 with 84 | // a scoped addressing zone ("fe80::1cc0:3e8c:119f:c2e1%ens18"). 85 | type IPV6[T constraint.Text] = Custom[T, validate.IPV6[T]] 86 | 87 | // MAC accepts an IEEE 802 MAC-48, EUI-48, EUI-64, or a 20-octet IP over InfiniBand link-layer address. 88 | type MAC[T constraint.Text] = Custom[T, validate.MAC[T]] 89 | 90 | // CIDR accepts CIDR notation IP address and prefix length, 91 | // like "192.0.2.0/24" or "2001:db8::/32", as defined in RFC 4632 and RFC 4291. 92 | type CIDR[T constraint.Text] = Custom[T, validate.CIDR[T]] 93 | 94 | // Base64 accepts valid base64 encoded strings. 95 | type Base64[T constraint.Text] = Custom[T, validate.Base64[T]] 96 | 97 | // Charset0 accepts (possibly empty) text which contains only runes acceptable by filter. 98 | // See [Charset] for a non-empty variant. 99 | type Charset0[T constraint.Text, F charset.Filter] = Custom[T, validate.Charset0[T, F]] 100 | 101 | // Charset accepts non-empty text which contains only runes acceptable by filter. 102 | // See also [Charset0]. 103 | type Charset[T constraint.Text, F charset.Filter] = Custom[T, validate.Charset[T, F]] 104 | 105 | // Latitude accepts any number in the range [-90; 90]. 106 | // 107 | // See also [Longitude]. 108 | type Latitude[T constraint.Real] = Custom[T, validate.Latitude[T]] 109 | 110 | // Longitude accepts any number in the range [-180; 180]. 111 | // 112 | // See also [Latitude]. 113 | type Longitude[T constraint.Real] = Custom[T, validate.Longitude[T]] 114 | 115 | // InFuture accepts any time after current timestamp. 116 | // 117 | // See also [InPast]. 118 | type InPast[T constraint.Time] = Custom[T, validate.InPast[T]] 119 | 120 | // InFuture accepts any time after current timestamp. 121 | // 122 | // See also [InPast]. 123 | type InFuture[T constraint.Time] = Custom[T, validate.InFuture[T]] 124 | 125 | // Unique accepts a slice-like of unique values. 126 | // 127 | // See [UniqueSlice] for a slice shortcut. 128 | type Unique[S ~[]T, T comparable] = Custom[S, validate.Unique[S, T]] 129 | 130 | // Unique accepts a slice of unique values. 131 | // 132 | // See [Unique] for a more generic version. 133 | type UniqueSlice[T comparable] = Custom[[]T, validate.UniqueSlice[T]] 134 | 135 | // NonEmpty accepts a non-empty slice-like (len > 0). 136 | // 137 | // See [NonEmptySlice] for a slice shortcut. 138 | type NonEmpty[S ~[]T, T any] = Custom[S, validate.NonEmpty[S, T]] 139 | 140 | // NonEmptySlice accepts a non-empty slice (len > 0). 141 | // 142 | // See [NonEmpty] for a more generic version. 143 | type NonEmptySlice[T comparable] = Custom[[]T, validate.NonEmptySlice[T]] 144 | 145 | // MIME accepts RFC 1521 mime type string. 146 | type MIME[T constraint.Text] = Custom[T, validate.MIME[T]] 147 | 148 | // UUID accepts a properly formatted UUID in one of the following formats: 149 | // - xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx 150 | // - urn:uuid:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx 151 | // - xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx 152 | // - {xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx} 153 | type UUID[T constraint.Text] = Custom[T, validate.UUID[T]] 154 | 155 | // JSON accepts valid json encoded text. 156 | type JSON[T constraint.Text] = Custom[T, validate.JSON[T]] 157 | 158 | // CountryAlpha2 accepts case-insensitive ISO 3166 2-letter country code. 159 | type CountryAlpha2[T constraint.Text] = Custom[T, validate.CountryAlpha2[T]] 160 | 161 | // CountryAlpha3 accepts case-insensitive ISO 3166 3-letter country code. 162 | type CountryAlpha3[T constraint.Text] = Custom[T, validate.CountryAlpha3[T]] 163 | 164 | // CountryAlpha accepts either [CountryAlpha2] or [CountryAlpha3]. 165 | type CountryAlpha[T constraint.Text] = Custom[T, validate.CountryAlpha[T]] 166 | 167 | // CurrencyAlpha accepts case-insensitive ISO 4217 alphabetic currency code. 168 | type CurrencyAlpha[T constraint.Text] = Custom[T, validate.CurrencyAlpha[T]] 169 | 170 | // LangAlpha2 accepts case-insensitive ISO 639 2-letter language code. 171 | type LangAlpha2[T constraint.Text] = Custom[T, validate.LangAlpha2[T]] 172 | 173 | // LangAlpha3 accepts case-insensitive ISO 639 3-letter language code. 174 | type LangAlpha3[T constraint.Text] = Custom[T, validate.LangAlpha3[T]] 175 | 176 | // LangAlpha accepts either [LangAlpha2] or [LangAlpha3]. 177 | type LangAlpha[T constraint.Text] = Custom[T, validate.LangAlpha[T]] 178 | 179 | -------------------------------------------------------------------------------- /optional/optional_test.go: -------------------------------------------------------------------------------- 1 | package optional 2 | 3 | import ( 4 | "encoding/json" 5 | "reflect" 6 | "testing" 7 | 8 | "github.com/metafates/schema/internal/testutil" 9 | ) 10 | 11 | func TestCustom_Parse(t *testing.T) { 12 | for _, tc := range []struct { 13 | name string 14 | value any 15 | wantErr bool 16 | }{ 17 | {name: "valid", value: 5}, 18 | {name: "empty", value: nil}, 19 | {name: "invalid", value: -2, wantErr: true}, 20 | } { 21 | t.Run(tc.name, func(t *testing.T) { 22 | var positive Positive[int] 23 | 24 | err := positive.Parse(tc.value) 25 | 26 | if tc.wantErr { 27 | testutil.Error(t, err) 28 | testutil.Equal(t, false, positive.validated) 29 | testutil.Equal(t, false, positive.hasValue) 30 | testutil.Equal(t, 0, positive.value) 31 | } else { 32 | testutil.NoError(t, err) 33 | testutil.Equal(t, tc.value != nil, positive.validated) 34 | testutil.Equal(t, tc.value != nil, positive.hasValue) 35 | if tc.value != nil { 36 | testutil.Equal(t, reflect.ValueOf(tc.value).Convert(reflect.TypeFor[int]()).Interface().(int), positive.value) 37 | } 38 | testutil.NoPanic(t, func() { 39 | positive.Get() 40 | }) 41 | } 42 | }) 43 | } 44 | } 45 | 46 | func TestOptional(t *testing.T) { 47 | t.Run("missing value", func(t *testing.T) { 48 | var foo Any[string] 49 | 50 | testutil.NoError(t, json.Unmarshal([]byte(`null`), &foo)) 51 | 52 | testutil.Equal(t, false, foo.hasValue) 53 | testutil.Equal(t, foo.hasValue, foo.HasValue()) 54 | testutil.Equal(t, "", foo.value) 55 | 56 | testutil.NoError(t, foo.TypeValidate()) 57 | testutil.Equal(t, false, foo.validated) 58 | 59 | testutil.Panic(t, func() { foo.Must() }) 60 | testutil.NoPanic(t, func() { foo.Get() }) 61 | testutil.NoPanic(t, func() { foo.MarshalJSON() }) 62 | testutil.NoPanic(t, func() { foo.MarshalText() }) 63 | testutil.NoPanic(t, func() { foo.MarshalBinary() }) 64 | testutil.NoPanic(t, func() { foo.GobEncode() }) 65 | testutil.NoPanic(t, func() { foo.Value() }) 66 | }) 67 | 68 | t.Run("invalid value", func(t *testing.T) { 69 | var foo Positive[int] 70 | 71 | testutil.NoError(t, json.Unmarshal([]byte(`-24`), &foo)) 72 | 73 | testutil.Equal(t, true, foo.hasValue) 74 | testutil.Equal(t, foo.hasValue, foo.HasValue()) 75 | testutil.Equal(t, -24, foo.value) 76 | 77 | testutil.Error(t, foo.TypeValidate()) 78 | testutil.Equal(t, false, foo.validated) 79 | 80 | testutil.Panic(t, func() { foo.Must() }) 81 | testutil.Panic(t, func() { foo.Get() }) 82 | testutil.Panic(t, func() { foo.MarshalJSON() }) 83 | testutil.Panic(t, func() { foo.MarshalText() }) 84 | testutil.Panic(t, func() { foo.MarshalBinary() }) 85 | testutil.Panic(t, func() { foo.GobEncode() }) 86 | testutil.Panic(t, func() { foo.Value() }) 87 | }) 88 | 89 | t.Run("nested invalid value", func(t *testing.T) { 90 | type Foo struct { 91 | Field Positive[int] 92 | } 93 | 94 | var foo Any[Foo] 95 | 96 | testutil.NoError(t, json.Unmarshal([]byte(`{"field":-1}`), &foo)) 97 | testutil.Error(t, foo.TypeValidate()) 98 | }) 99 | 100 | t.Run("valid value", func(t *testing.T) { 101 | var foo Positive[int] 102 | 103 | testutil.NoError(t, json.Unmarshal([]byte(`24`), &foo)) 104 | 105 | testutil.Equal(t, true, foo.hasValue) 106 | testutil.Equal(t, foo.hasValue, foo.HasValue()) 107 | testutil.Equal(t, 24, foo.value) 108 | 109 | testutil.NoError(t, foo.TypeValidate()) 110 | testutil.Equal(t, true, foo.validated) 111 | 112 | testutil.NoPanic(t, func() { foo.Must() }) 113 | testutil.NoPanic(t, func() { foo.Get() }) 114 | testutil.NoPanic(t, func() { foo.MarshalJSON() }) 115 | testutil.NoPanic(t, func() { foo.MarshalText() }) 116 | testutil.NoPanic(t, func() { foo.MarshalBinary() }) 117 | testutil.NoPanic(t, func() { foo.GobEncode() }) 118 | testutil.NoPanic(t, func() { foo.Value() }) 119 | 120 | t.Run("reuse as invalid", func(t *testing.T) { 121 | testutil.NoError(t, json.Unmarshal([]byte(`24`), &foo)) 122 | 123 | testutil.NoError(t, json.Unmarshal([]byte(`-24`), &foo)) 124 | 125 | testutil.Equal(t, true, foo.hasValue) 126 | testutil.Equal(t, -24, foo.value) 127 | 128 | testutil.Error(t, foo.TypeValidate()) 129 | testutil.Equal(t, false, foo.validated) 130 | 131 | testutil.Panic(t, func() { foo.Must() }) 132 | testutil.Panic(t, func() { foo.Get() }) 133 | testutil.Panic(t, func() { foo.MarshalJSON() }) 134 | testutil.Panic(t, func() { foo.MarshalText() }) 135 | testutil.Panic(t, func() { foo.MarshalBinary() }) 136 | testutil.Panic(t, func() { foo.GobEncode() }) 137 | testutil.Panic(t, func() { foo.Value() }) 138 | }) 139 | }) 140 | } 141 | -------------------------------------------------------------------------------- /optional/sql.go: -------------------------------------------------------------------------------- 1 | package optional 2 | 3 | import ( 4 | "database/sql" 5 | "database/sql/driver" 6 | "fmt" 7 | 8 | "github.com/metafates/schema/validate" 9 | ) 10 | 11 | var _ interface { 12 | sql.Scanner 13 | driver.Valuer 14 | } = (*Custom[any, validate.Validator[any]])(nil) 15 | 16 | // Scan implements the [sql.Scanner] interface. 17 | // 18 | // Use [Custom.Parse] instead if you need to construct this value manually. 19 | func (c *Custom[T, V]) Scan(src any) error { 20 | if src == nil { 21 | *c = Custom[T, V]{} 22 | 23 | return nil 24 | } 25 | 26 | var value T 27 | 28 | if scanner, ok := any(&value).(sql.Scanner); ok { 29 | if err := scanner.Scan(src); err != nil { 30 | return err 31 | } 32 | 33 | *c = Custom[T, V]{value: value, hasValue: true} 34 | 35 | return nil 36 | } 37 | 38 | if converted, err := driver.DefaultParameterConverter.ConvertValue(src); err == nil { 39 | if v, ok := converted.(T); ok { 40 | *c = Custom[T, V]{value: v, hasValue: true} 41 | 42 | return nil 43 | } 44 | } 45 | 46 | var nullable sql.Null[T] 47 | 48 | if err := nullable.Scan(src); err != nil { 49 | return err 50 | } 51 | 52 | if nullable.Valid { 53 | *c = Custom[T, V]{value: nullable.V, hasValue: true} 54 | } else { 55 | *c = Custom[T, V]{} 56 | } 57 | 58 | return nil 59 | } 60 | 61 | // Value implements the [driver.Valuer] interface. 62 | // 63 | // Use [Custom.Get] method instead for getting the go value. 64 | func (c Custom[T, V]) Value() (driver.Value, error) { 65 | if !c.hasValue { 66 | //nolint:nilnil 67 | return nil, nil 68 | } 69 | 70 | value, err := driver.DefaultParameterConverter.ConvertValue(c.Must()) 71 | if err != nil { 72 | return nil, fmt.Errorf("convert: %w", err) 73 | } 74 | 75 | return value, nil 76 | } 77 | -------------------------------------------------------------------------------- /optional/text.go: -------------------------------------------------------------------------------- 1 | package optional 2 | 3 | import ( 4 | "encoding" 5 | 6 | "github.com/metafates/schema/validate" 7 | ) 8 | 9 | var _ interface { 10 | encoding.TextMarshaler 11 | encoding.TextUnmarshaler 12 | } = (*Custom[any, validate.Validator[any]])(nil) 13 | 14 | // UnmarshalText implements the [encoding.TextUnmarshaler] interface. 15 | func (c *Custom[T, V]) UnmarshalText(data []byte) error { 16 | return c.UnmarshalJSON(data) 17 | } 18 | 19 | // MarshalText implements the [encoding.TextMarshaler] interface. 20 | func (c Custom[T, V]) MarshalText() ([]byte, error) { 21 | return c.MarshalJSON() 22 | } 23 | -------------------------------------------------------------------------------- /parse/UserWithGen_schema_test.go: -------------------------------------------------------------------------------- 1 | // Code generated by schemagen; DO NOT EDIT. 2 | 3 | package parse_test 4 | 5 | import ( 6 | "fmt" 7 | "github.com/metafates/schema/optional" 8 | "github.com/metafates/schema/required" 9 | "github.com/metafates/schema/validate" 10 | "github.com/metafates/schema/validate/charset" 11 | "time" 12 | ) 13 | 14 | // Ensure that [UserWithGen] type was not changed 15 | func _() { 16 | type locked struct { 17 | ID required.Custom[string, validate.UUID[string]] 18 | Name required.Custom[string, validate.Charset[string, charset.Print]] 19 | Birth optional.Custom[time.Time, validate.InPast[time.Time]] 20 | FavoriteNumber int 21 | Friends []Friend 22 | } 23 | var v UserWithGen 24 | // Compiler error signifies that the type definition have changed. 25 | // Re-run the schemagen command to regenerate this file. 26 | _ = locked(v) 27 | } 28 | 29 | // TypeValidate implements the [validate.TypeValidateable] interface. 30 | func (x *UserWithGen) TypeValidate() error { 31 | err0 := validate.Validate(&x.ID) 32 | if err0 != nil { 33 | return validate.ValidationError{Inner: err0}.WithPath(fmt.Sprintf(".ID")) 34 | } 35 | err1 := validate.Validate(&x.Name) 36 | if err1 != nil { 37 | return validate.ValidationError{Inner: err1}.WithPath(fmt.Sprintf(".Name")) 38 | } 39 | err2 := validate.Validate(&x.Birth) 40 | if err2 != nil { 41 | return validate.ValidationError{Inner: err2}.WithPath(fmt.Sprintf(".Birth")) 42 | } 43 | for i0 := range x.Friends { 44 | { 45 | err3 := validate.Validate(&x.Friends[i0]) 46 | if err3 != nil { 47 | return validate.ValidationError{Inner: err3}.WithPath(fmt.Sprintf(".Friends[%v]", i0)) 48 | } 49 | } 50 | } 51 | return nil 52 | } 53 | -------------------------------------------------------------------------------- /parse/error.go: -------------------------------------------------------------------------------- 1 | package parse 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "reflect" 7 | "strings" 8 | ) 9 | 10 | // InvalidParseError describes an invalid argument passed to [Parse]. 11 | // The argument to [Parse] must be a non-nil pointer. 12 | type InvalidParseError struct { 13 | Type reflect.Type 14 | } 15 | 16 | func (e InvalidParseError) Error() string { 17 | if e.Type == nil { 18 | return "Parse(nil)" 19 | } 20 | 21 | if e.Type.Kind() != reflect.Pointer { 22 | return "Parse(non-pointer " + e.Type.String() + ")" 23 | } 24 | 25 | return "Validate(nil " + e.Type.String() + ")" 26 | } 27 | 28 | type UnconvertableTypeError struct { 29 | Target, Original string 30 | } 31 | 32 | func (e UnconvertableTypeError) Error() string { 33 | return fmt.Sprintf("can not convert %s to %s", e.Original, e.Target) 34 | } 35 | 36 | type UnknownFieldError struct { 37 | Name string 38 | } 39 | 40 | func (e UnknownFieldError) Error() string { 41 | return "unknown field: " + e.Name 42 | } 43 | 44 | type ParseError struct { 45 | Msg string 46 | Inner error 47 | 48 | path string 49 | } 50 | 51 | // Path returns the path to the value which raised this error. 52 | func (e ParseError) Path() string { 53 | var recursive func(path []string, err error) []string 54 | 55 | recursive = func(path []string, err error) []string { 56 | var validationErr ParseError 57 | 58 | if !errors.As(err, &validationErr) { 59 | return path 60 | } 61 | 62 | if validationErr.path != "" { 63 | path = append(path, validationErr.path) 64 | } 65 | 66 | return recursive(path, validationErr.Inner) 67 | } 68 | 69 | return strings.Join(recursive(nil, e), "") 70 | } 71 | 72 | func (e ParseError) Error() string { 73 | return "parse: " + e.error() 74 | } 75 | 76 | func (e ParseError) Unwrap() error { 77 | return e.Inner 78 | } 79 | 80 | func (e ParseError) error() string { 81 | segments := make([]string, 0, 3) 82 | 83 | if e.path != "" { 84 | segments = append(segments, e.path) 85 | } 86 | 87 | if e.Msg != "" { 88 | segments = append(segments, e.Msg) 89 | } 90 | 91 | if e.Inner != nil { 92 | var pe ParseError 93 | 94 | if errors.As(e.Inner, &pe) { 95 | segments = append(segments, pe.error()) 96 | } else { 97 | segments = append(segments, e.Inner.Error()) 98 | } 99 | } 100 | 101 | // path: msg: inner error 102 | return strings.Join(segments, ": ") 103 | } 104 | -------------------------------------------------------------------------------- /parse/options.go: -------------------------------------------------------------------------------- 1 | package parse 2 | 3 | func defaultConfig() config { 4 | return config{ 5 | DisallowUnknownFields: false, 6 | RenameFunc: func(s string) string { return s }, 7 | } 8 | } 9 | 10 | type config struct { 11 | DisallowUnknownFields bool 12 | RenameFunc RenameFunc 13 | } 14 | 15 | type RenameFunc func(string) string 16 | 17 | // Option modifies the parsing logic. 18 | type Option func(cfg *config) 19 | 20 | // WithDisallowUnknownFields is an option that will return an error if unknown field is supplied. 21 | func WithDisallowUnknownFields() Option { 22 | return func(cfg *config) { 23 | cfg.DisallowUnknownFields = true 24 | } 25 | } 26 | 27 | // WithRenameFunc is an option that will rename src fields/keys during parsing before matching with dst fields. 28 | func WithRenameFunc(f RenameFunc) Option { 29 | return func(cfg *config) { 30 | cfg.RenameFunc = f 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /parse/parse.go: -------------------------------------------------------------------------------- 1 | // Package parse provides parsing functionality for converting similar looking values into others with validation. 2 | package parse 3 | 4 | import ( 5 | "fmt" 6 | "reflect" 7 | "strconv" 8 | 9 | "github.com/metafates/schema/validate" 10 | ) 11 | 12 | type Parser interface { 13 | Parse(v any) error 14 | } 15 | 16 | // Parse attempts to copy data from src into dst. If dst implements the [Parser] interface, 17 | // Parse simply calls dst.Parse(src). Otherwise, it uses reflection to assign fields or 18 | // elements to dst. To succeed, dst must be a non-nil pointer to a settable value. 19 | // 20 | // The function supports struct-to-struct, map-to-struct, and slice-to-slice copying, 21 | // as well as direct conversions between basic types (including []byte to string). 22 | // If src is nil, no assignment is performed. If dst is not a valid pointer, an [InvalidParseError] 23 | // is returned. If a type conversion is not possible, an [UnconvertableTypeError] is returned. 24 | // 25 | // Successfully parsed value is already validated and can be used safely. 26 | // 27 | // Any errors encountered during parsing are wrapped in a [ParseError]. 28 | // 29 | // Parse also accepts options. See [Option]. 30 | func Parse(src, dst any, options ...Option) error { 31 | if parser, ok := dst.(Parser); ok { 32 | if err := parser.Parse(src); err != nil { 33 | return ParseError{Inner: err} 34 | } 35 | 36 | return nil 37 | } 38 | 39 | // dst must be pointer to a settable value 40 | v := reflect.ValueOf(dst) 41 | if v.Kind() != reflect.Pointer || v.IsNil() { 42 | return InvalidParseError{Type: v.Type()} 43 | } 44 | 45 | cfg := defaultConfig() 46 | 47 | for _, apply := range options { 48 | apply(&cfg) 49 | } 50 | 51 | if err := parse(src, v.Elem(), "", &cfg); err != nil { 52 | return err 53 | } 54 | 55 | if err := validate.Validate(dst); err != nil { 56 | return err 57 | } 58 | 59 | return nil 60 | } 61 | 62 | func parse(src any, dst reflect.Value, dstPath string, cfg *config) error { 63 | // If src is nil, we stop (do not set anything). 64 | if src == nil { 65 | return nil 66 | } 67 | 68 | if dst.CanAddr() { 69 | if parser, ok := dst.Addr().Interface().(Parser); ok { 70 | // Let the target type parse "src" however it likes 71 | if err := parser.Parse(src); err != nil { 72 | return ParseError{Inner: err, path: dstPath} 73 | } 74 | 75 | return nil 76 | } 77 | } 78 | 79 | vSrc := reflect.ValueOf(src) 80 | 81 | if vSrc.Kind() == reflect.Pointer { 82 | if vSrc.IsNil() { 83 | return nil 84 | } 85 | 86 | vSrc = vSrc.Elem() 87 | } 88 | 89 | switch dst.Kind() { 90 | case reflect.Struct: 91 | return parseToStruct(vSrc, dst, dstPath, cfg) 92 | 93 | case reflect.Slice: 94 | return parseToSlice(vSrc, dst, dstPath, cfg) 95 | 96 | default: 97 | return parseToBasic(vSrc, dst) 98 | } 99 | } 100 | 101 | func parseToBasic(src reflect.Value, dst reflect.Value) error { 102 | // For basic types, try direct conversion. 103 | if src.CanConvert(dst.Type()) { 104 | dst.Set(src.Convert(dst.Type())) 105 | 106 | return nil 107 | } 108 | 109 | // Special-case []byte -> string 110 | if dst.Kind() == reflect.String && 111 | src.Kind() == reflect.Slice && 112 | src.Type().Elem().Kind() == reflect.Uint8 { 113 | dst.SetString(string(src.Bytes())) 114 | 115 | return nil 116 | } 117 | 118 | return ParseError{ 119 | Inner: UnconvertableTypeError{ 120 | Target: dst.Type().String(), 121 | Original: reflect.TypeOf(src).String(), 122 | }, 123 | } 124 | } 125 | 126 | func parseToStruct(src reflect.Value, dst reflect.Value, dstPath string, cfg *config) error { 127 | // If dst is a struct, then src should be either a struct or a map. 128 | switch src.Kind() { 129 | case reflect.Map: 130 | return parseMapToStruct(src, dst, dstPath, cfg) 131 | 132 | case reflect.Struct: 133 | return parseStructToStruct(src, dst, dstPath, cfg) 134 | 135 | default: 136 | return ParseError{ 137 | Msg: fmt.Sprintf("cannot set struct from %T", src), 138 | path: dstPath, 139 | } 140 | } 141 | } 142 | 143 | func parseStructToStruct(src reflect.Value, dst reflect.Value, dstPath string, cfg *config) error { 144 | // We can copy fields from one struct to the other if they match by name. 145 | srcType := src.Type() 146 | 147 | for i := range srcType.NumField() { 148 | fieldSrc := srcType.Field(i) 149 | if !fieldSrc.IsExported() { 150 | continue 151 | } 152 | 153 | fieldName := cfg.RenameFunc(fieldSrc.Name) 154 | 155 | fieldDst := dst.FieldByName(fieldName) 156 | 157 | if !fieldDst.IsValid() || !fieldDst.CanSet() { 158 | if cfg.DisallowUnknownFields { 159 | return ParseError{ 160 | Inner: UnknownFieldError{Name: fieldName}, 161 | } 162 | } 163 | 164 | continue 165 | } 166 | 167 | if err := parse(src.Field(i).Interface(), fieldDst, dstPath+"."+fieldName, cfg); err != nil { 168 | return err 169 | } 170 | } 171 | 172 | return nil 173 | } 174 | 175 | func parseMapToStruct(src reflect.Value, dst reflect.Value, dstPath string, cfg *config) error { 176 | // For each key in the map, look for a field of the same name in dst. 177 | for _, mk := range src.MapKeys() { 178 | // We only handle string keys here. 179 | keyStr, ok := mk.Interface().(string) 180 | if !ok { 181 | return ParseError{ 182 | Msg: fmt.Sprintf("map key %v is not a string, cannot set struct field", mk), 183 | path: dstPath, 184 | } 185 | } 186 | 187 | keyStr = cfg.RenameFunc(keyStr) 188 | 189 | field := dst.FieldByName(keyStr) 190 | 191 | // If not found or not settable, ignore. 192 | if !field.IsValid() || !field.CanSet() { 193 | if cfg.DisallowUnknownFields { 194 | return ParseError{ 195 | Inner: UnknownFieldError{Name: keyStr}, 196 | } 197 | } 198 | 199 | continue 200 | } 201 | 202 | if err := parse(src.MapIndex(mk).Interface(), field, dstPath+"."+keyStr, cfg); err != nil { 203 | return err 204 | } 205 | } 206 | 207 | return nil 208 | } 209 | 210 | func parseToSlice(src reflect.Value, dst reflect.Value, dstPath string, cfg *config) error { 211 | // If dst is a slice, src must be a slice too. 212 | if src.Kind() != reflect.Slice { 213 | return ParseError{ 214 | Msg: fmt.Sprintf("cannot set slice from %T", src), 215 | path: dstPath, 216 | } 217 | } 218 | 219 | // Create a new slice of the appropriate type/length. 220 | slice := reflect.MakeSlice(dst.Type(), src.Len(), src.Len()) 221 | 222 | for i := range src.Len() { 223 | if err := parse(src.Index(i).Interface(), slice.Index(i), "["+strconv.Itoa(i)+"]", cfg); err != nil { 224 | return err 225 | } 226 | } 227 | 228 | dst.Set(slice) 229 | 230 | return nil 231 | } 232 | -------------------------------------------------------------------------------- /parse/parse_test.go: -------------------------------------------------------------------------------- 1 | package parse_test 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "strings" 7 | "testing" 8 | "time" 9 | 10 | "github.com/metafates/schema/internal/testutil" 11 | "github.com/metafates/schema/optional" 12 | "github.com/metafates/schema/parse" 13 | "github.com/metafates/schema/required" 14 | "github.com/metafates/schema/validate" 15 | "github.com/metafates/schema/validate/charset" 16 | ) 17 | 18 | func ExampleParse() { 19 | type User struct { 20 | Name required.Any[string] `json:"such_tags_are_ignored_by_default"` 21 | Comment string 22 | Age int 23 | } 24 | 25 | var user1, user2 User 26 | 27 | parse.Parse(map[string]any{ 28 | "Name": "john", 29 | "Comment": "lorem ipsum", 30 | "Age": 99, 31 | "UnknownField": "this field will be ignored", 32 | }, &user1) 33 | 34 | parse.Parse(struct { 35 | Name, Comment string 36 | Age uint8 // types will be converted 37 | }{ 38 | Name: "jane", 39 | Comment: "dolor sit", 40 | Age: 55, 41 | }, &user2) 42 | 43 | fmt.Printf("user1: name=%q comment=%q age=%v\n", user1.Name.Get(), user1.Comment, user1.Age) 44 | fmt.Printf("user2: name=%q comment=%q age=%v\n", user2.Name.Get(), user2.Comment, user2.Age) 45 | 46 | // Output: 47 | // user1: name="john" comment="lorem ipsum" age=99 48 | // user2: name="jane" comment="dolor sit" age=55 49 | } 50 | 51 | func TestParse(t *testing.T) { 52 | t.Run("basic dst", func(t *testing.T) { 53 | for _, tc := range []struct { 54 | name string 55 | value any 56 | want int 57 | wantErr bool 58 | }{ 59 | { 60 | name: "same type", 61 | value: int(42), 62 | want: 42, 63 | }, 64 | { 65 | name: "type conversion", 66 | value: float64(42.000000), 67 | want: 42, 68 | }, 69 | { 70 | name: "invalid type", 71 | value: "hello", 72 | want: 42, 73 | wantErr: true, 74 | }, 75 | { 76 | name: "loosely type conversion", 77 | value: 42.42, 78 | want: 42, 79 | }, 80 | { 81 | name: "non-nil pointer", 82 | value: func() *int { n := 42; return &n }(), 83 | want: 42, 84 | }, 85 | { 86 | name: "nil pointer", 87 | value: (*int)(nil), 88 | want: 0, 89 | }, 90 | } { 91 | t.Run(tc.name, func(t *testing.T) { 92 | var dst int 93 | 94 | err := parse.Parse(tc.value, &dst) 95 | 96 | if tc.wantErr { 97 | testutil.Error(t, err) 98 | } else { 99 | testutil.NoError(t, err) 100 | testutil.Equal(t, tc.want, dst) 101 | } 102 | }) 103 | } 104 | }) 105 | 106 | t.Run("slice dst", func(t *testing.T) { 107 | type Foo struct { 108 | Name string 109 | } 110 | 111 | for _, tc := range []struct { 112 | name string 113 | want []Foo 114 | value any 115 | wantErr bool 116 | }{ 117 | { 118 | name: "valid maps", 119 | want: []Foo{ 120 | {Name: "Foo"}, 121 | {Name: "Bar"}, 122 | }, 123 | value: []map[string]string{ 124 | {"Name": "Foo"}, 125 | {"Name": "Bar"}, 126 | }, 127 | }, 128 | { 129 | name: "valid structs", 130 | want: []Foo{ 131 | {Name: "Foo"}, 132 | {Name: "Bar"}, 133 | }, 134 | value: []struct{ Name string }{ 135 | {Name: "Foo"}, 136 | {Name: "Bar"}, 137 | }, 138 | }, 139 | { 140 | name: "nil", 141 | want: nil, 142 | value: nil, 143 | }, 144 | { 145 | name: "partial", 146 | want: []Foo{ 147 | {Name: ""}, 148 | {Name: "Bar"}, 149 | }, 150 | value: []map[string]string{ 151 | {"Surname": "Foo"}, 152 | {"Name": "Bar"}, 153 | }, 154 | }, 155 | } { 156 | t.Run(tc.name, func(t *testing.T) { 157 | var dst []Foo 158 | 159 | err := parse.Parse(tc.value, &dst) 160 | 161 | if tc.wantErr { 162 | testutil.Error(t, err) 163 | } else { 164 | testutil.NoError(t, err) 165 | testutil.DeepEqual(t, tc.want, dst) 166 | } 167 | }) 168 | } 169 | }) 170 | 171 | t.Run("struct dst", func(t *testing.T) { 172 | type Additional struct { 173 | CreatedAt required.InPast[time.Time] 174 | IsAdmin bool 175 | Theme optional.Any[string] 176 | Foo optional.Any[int] 177 | } 178 | 179 | type Target struct { 180 | Name required.NonZero[string] `json:"json_tag_should_be_ignored"` 181 | Bio string 182 | IDs []int 183 | 184 | Extra Additional 185 | } 186 | 187 | sample := Target{ 188 | Name: func() (name required.NonZero[string]) { 189 | name.MustParse("john") 190 | 191 | return name 192 | }(), 193 | IDs: []int{1, 100}, 194 | Bio: "...", 195 | Extra: Additional{ 196 | CreatedAt: func() (v required.InPast[time.Time]) { 197 | v.MustParse(time.Now().Add(-time.Hour)) 198 | 199 | return v 200 | }(), 201 | IsAdmin: true, 202 | Theme: func() (v optional.Any[string]) { 203 | v.MustParse("dark") 204 | 205 | return v 206 | }(), 207 | }, 208 | } 209 | 210 | for _, tc := range []struct { 211 | name string 212 | value any 213 | want Target 214 | wantErr bool 215 | options []parse.Option 216 | }{ 217 | { 218 | name: "valid map with all fields", 219 | want: sample, 220 | value: map[string]any{ 221 | "Name": sample.Name.Get(), 222 | "IDs": []uint8{1, 100}, // types are converted 223 | "Bio": sample.Bio, 224 | "Extra": map[string]any{ 225 | "CreatedAt": sample.Extra.CreatedAt.Get(), 226 | "IsAdmin": sample.Extra.IsAdmin, 227 | "Theme": sample.Extra.Theme.GetPtr(), 228 | "Foo": sample.Extra.Foo.GetPtr(), 229 | }, 230 | }, 231 | }, 232 | { 233 | name: "valid map with partial fields", 234 | want: Target{Name: sample.Name, Extra: Additional{CreatedAt: sample.Extra.CreatedAt}}, 235 | value: map[string]any{ 236 | "Name": sample.Name, 237 | "Extra": map[string]any{ 238 | "CreatedAt": sample.Extra.CreatedAt, 239 | }, 240 | }, 241 | }, 242 | { 243 | name: "invalid map with partial fields", 244 | want: Target{Bio: sample.Bio}, 245 | value: map[string]any{ 246 | "Bio": sample.Bio, 247 | }, 248 | wantErr: true, 249 | }, 250 | { 251 | name: "invalid struct with partial fields", 252 | want: Target{Name: sample.Name, Bio: sample.Bio}, 253 | value: struct{ Name, Bio string }{Name: sample.Name.Get(), Bio: sample.Bio}, 254 | wantErr: true, 255 | }, 256 | { 257 | name: "valid struct with partial fields", 258 | want: Target{Name: sample.Name, Extra: Additional{CreatedAt: sample.Extra.CreatedAt}}, 259 | value: struct { 260 | Name string 261 | Extra struct{ CreatedAt time.Time } 262 | }{ 263 | Name: sample.Name.Get(), 264 | Extra: struct{ CreatedAt time.Time }{CreatedAt: sample.Extra.CreatedAt.Get()}, 265 | }, 266 | }, 267 | { 268 | name: "valid same struct", 269 | want: sample, 270 | value: sample, 271 | }, 272 | { 273 | name: "invalid type", 274 | want: sample, 275 | value: map[string]any{ 276 | "Name": 42.42, 277 | }, 278 | wantErr: true, 279 | }, 280 | { 281 | name: "unknown field", 282 | value: map[string]any{ 283 | "Name": "john", 284 | "Foo": 9, 285 | }, 286 | options: []parse.Option{parse.WithDisallowUnknownFields()}, 287 | wantErr: true, 288 | }, 289 | { 290 | name: "renamed fields", 291 | want: Target{Name: sample.Name, Extra: Additional{CreatedAt: sample.Extra.CreatedAt}}, 292 | value: map[string]any{ 293 | "name": sample.Name.Get(), 294 | "extra": map[string]any{ 295 | "createdAt": sample.Extra.CreatedAt.Get(), 296 | }, 297 | }, 298 | options: []parse.Option{ 299 | parse.WithDisallowUnknownFields(), 300 | parse.WithRenameFunc(func(s string) string { 301 | return strings.ToUpper(string(s[0])) + s[1:] 302 | }), 303 | }, 304 | }, 305 | } { 306 | t.Run(tc.name, func(t *testing.T) { 307 | var dst Target 308 | 309 | err := parse.Parse(tc.value, &dst, tc.options...) 310 | 311 | if tc.wantErr { 312 | testutil.Error(t, err) 313 | } else { 314 | testutil.NoError(t, err) 315 | testutil.DeepEqual(t, tc.want, dst) 316 | } 317 | }) 318 | } 319 | }) 320 | } 321 | 322 | type Friend struct { 323 | ID required.UUID[string] 324 | Name required.Charset[string, charset.Print] 325 | } 326 | 327 | type User struct { 328 | ID required.UUID[string] 329 | Name required.Charset[string, charset.Print] 330 | Birth optional.InPast[time.Time] 331 | 332 | FavoriteNumber int 333 | 334 | Friends []Friend 335 | } 336 | 337 | //go:generate schemagen -type UserWithGen 338 | type UserWithGen struct { 339 | ID required.UUID[string] 340 | Name required.Charset[string, charset.Print] 341 | Birth optional.InPast[time.Time] 342 | 343 | FavoriteNumber int 344 | 345 | Friends []Friend 346 | } 347 | 348 | func BenchmarkParse(b *testing.B) { 349 | b.Run("manual", func(b *testing.B) { 350 | var user User 351 | 352 | for b.Loop() { 353 | if err := user.ID.Parse("2c376d16-321d-43b3-8648-2e64798cc6b3"); err != nil { 354 | b.Fatal(err) 355 | } 356 | 357 | if err := user.Name.Parse("john"); err != nil { 358 | b.Fatal(err) 359 | } 360 | 361 | user.FavoriteNumber = 42 362 | user.Friends = make([]Friend, 1) 363 | 364 | if err := user.Friends[0].ID.Parse("7f735045-c8d2-4a60-9184-0fc033c40a6a"); err != nil { 365 | b.Fatal(err) 366 | } 367 | 368 | if err := user.Friends[0].Name.Parse("jane"); err != nil { 369 | b.Fatal(err) 370 | } 371 | } 372 | 373 | _ = user 374 | }) 375 | 376 | b.Run("without codegen", func(b *testing.B) { 377 | b.Run("parse", func(b *testing.B) { 378 | data := map[string]any{ 379 | "ID": "2c376d16-321d-43b3-8648-2e64798cc6b3", 380 | "Name": "john", 381 | "FavoriteNumber": 42, 382 | "Friends": []map[string]any{ 383 | {"ID": "7f735045-c8d2-4a60-9184-0fc033c40a6a", "Name": "jane"}, 384 | }, 385 | } 386 | 387 | var user User 388 | 389 | for b.Loop() { 390 | if err := parse.Parse(data, &user); err != nil { 391 | b.Fatal(err) 392 | } 393 | } 394 | 395 | _ = user 396 | }) 397 | 398 | b.Run("unmarshal", func(b *testing.B) { 399 | data := []byte(` 400 | { 401 | "ID": "2c376d16-321d-43b3-8648-2e64798cc6b3", 402 | "Name": "john", 403 | "FavoriteNumber": 42, 404 | "Friends": [ 405 | {"ID": "7f735045-c8d2-4a60-9184-0fc033c40a6a", "Name": "jane"} 406 | ] 407 | } 408 | `) 409 | 410 | var user User 411 | 412 | for b.Loop() { 413 | if err := json.Unmarshal(data, &user); err != nil { 414 | b.Fatal(err) 415 | } 416 | 417 | if err := validate.Validate(&user); err != nil { 418 | b.Fatal(err) 419 | } 420 | } 421 | 422 | _ = user 423 | }) 424 | }) 425 | 426 | b.Run("with codegen", func(b *testing.B) { 427 | b.Run("parse", func(b *testing.B) { 428 | data := map[string]any{ 429 | "ID": "2c376d16-321d-43b3-8648-2e64798cc6b3", 430 | "Name": "john", 431 | "FavoriteNumber": 42, 432 | "Friends": []map[string]any{ 433 | {"ID": "7f735045-c8d2-4a60-9184-0fc033c40a6a", "Name": "jane"}, 434 | }, 435 | } 436 | 437 | var user UserWithGen 438 | 439 | for b.Loop() { 440 | if err := parse.Parse(data, &user); err != nil { 441 | b.Fatal(err) 442 | } 443 | } 444 | 445 | _ = user 446 | }) 447 | 448 | b.Run("unmarshal", func(b *testing.B) { 449 | data := []byte(` 450 | { 451 | "ID": "2c376d16-321d-43b3-8648-2e64798cc6b3", 452 | "Name": "john", 453 | "FavoriteNumber": 42, 454 | "Friends": [ 455 | {"ID": "7f735045-c8d2-4a60-9184-0fc033c40a6a", "Name": "jane"} 456 | ] 457 | } 458 | `) 459 | 460 | var user UserWithGen 461 | 462 | for b.Loop() { 463 | if err := json.Unmarshal(data, &user); err != nil { 464 | b.Fatal(err) 465 | } 466 | 467 | if err := validate.Validate(&user); err != nil { 468 | b.Fatal(err) 469 | } 470 | } 471 | 472 | _ = user 473 | }) 474 | }) 475 | } 476 | -------------------------------------------------------------------------------- /required/binary.go: -------------------------------------------------------------------------------- 1 | package required 2 | 3 | import ( 4 | "encoding" 5 | 6 | "github.com/metafates/schema/validate" 7 | ) 8 | 9 | var _ interface { 10 | encoding.BinaryUnmarshaler 11 | encoding.BinaryMarshaler 12 | } = (*Custom[any, validate.Validator[any]])(nil) 13 | 14 | // UnmarshalBinary implements the [encoding.BinaryUnmarshaler] interface. 15 | func (c *Custom[T, V]) UnmarshalBinary(data []byte) error { 16 | return c.GobDecode(data) 17 | } 18 | 19 | // MarshalBinary implements the [encoding.BinaryMarshaler] interface. 20 | func (c Custom[T, V]) MarshalBinary() ([]byte, error) { 21 | return c.GobEncode() 22 | } 23 | -------------------------------------------------------------------------------- /required/custom.go: -------------------------------------------------------------------------------- 1 | // Package required provides types whose values must be present and pass validation. 2 | // 3 | // Required types support the following encoding/decoding formats: 4 | // - json 5 | // - sql 6 | // - text 7 | // - binary 8 | // - gob 9 | package required 10 | 11 | import ( 12 | "reflect" 13 | 14 | "github.com/metafates/schema/parse" 15 | "github.com/metafates/schema/validate" 16 | ) 17 | 18 | var ( 19 | ErrMissingValue = validate.ValidationError{Msg: "missing required value"} 20 | ErrParseNilValue = parse.ParseError{Msg: "nil value passed for parsing"} 21 | ) 22 | 23 | // Custom required type. 24 | // Errors if value is missing or did not pass the validation. 25 | type Custom[T any, V validate.Validator[T]] struct { 26 | value T 27 | hasValue bool 28 | validated bool 29 | } 30 | 31 | // TypeValidate implements the [validate.TypeValidateable] interface. 32 | // You should not call this function directly. 33 | func (c *Custom[T, V]) TypeValidate() error { 34 | if !c.hasValue { 35 | return ErrMissingValue 36 | } 37 | 38 | if err := (*new(V)).Validate(c.value); err != nil { 39 | return validate.ValidationError{Inner: err} 40 | } 41 | 42 | // validate nested types recursively 43 | if err := validate.Validate(&c.value); err != nil { 44 | return err 45 | } 46 | 47 | c.validated = true 48 | 49 | return nil 50 | } 51 | 52 | // Get returns the contained value. 53 | // Panics if value was not validated yet. 54 | func (c Custom[T, V]) Get() T { 55 | if !c.validated { 56 | panic("called Get() on unvalidated value") 57 | } 58 | 59 | return c.value 60 | } 61 | 62 | // Parse checks if given value is valid. 63 | // If it is, a value is used to initialize this type. 64 | // Value is converted to the target type T, if possible. If not - [parse.UnconvertableTypeError] is returned. 65 | // It is allowed to pass convertable type wrapped in required type. 66 | // 67 | // Parsed type is validated, therefore it is safe to call [Custom.Get] afterwards. 68 | func (c *Custom[T, V]) Parse(value any) error { 69 | if value == nil { 70 | return ErrParseNilValue 71 | } 72 | 73 | rValue := reflect.ValueOf(value) 74 | 75 | if rValue.Kind() == reflect.Pointer { 76 | if rValue.IsNil() { 77 | return ErrParseNilValue 78 | } 79 | 80 | rValue = rValue.Elem() 81 | } 82 | 83 | tType := reflect.TypeFor[T]() 84 | 85 | if _, ok := value.(interface{ isRequired() }); ok { 86 | // NOTE: ensure this method name is in sync with [Custom.Get] 87 | rValue = rValue.MethodByName("Get").Call(nil)[0] 88 | } 89 | 90 | if !rValue.CanConvert(tType) { 91 | return parse.ParseError{ 92 | Inner: parse.UnconvertableTypeError{ 93 | Target: tType.String(), 94 | Original: rValue.Type().String(), 95 | }, 96 | } 97 | } 98 | 99 | //nolint:forcetypeassert // checked already by CanConvert 100 | aux := Custom[T, V]{ 101 | value: rValue.Convert(tType).Interface().(T), 102 | hasValue: true, 103 | } 104 | 105 | if err := aux.TypeValidate(); err != nil { 106 | return err 107 | } 108 | 109 | *c = aux 110 | 111 | return nil 112 | } 113 | 114 | func (c *Custom[T, V]) MustParse(value any) { 115 | if err := c.Parse(value); err != nil { 116 | panic("MustParse failed") 117 | } 118 | } 119 | 120 | func (Custom[T, V]) isRequired() {} 121 | -------------------------------------------------------------------------------- /required/gob.go: -------------------------------------------------------------------------------- 1 | package required 2 | 3 | import ( 4 | "bytes" 5 | "encoding/gob" 6 | "errors" 7 | 8 | "github.com/metafates/schema/validate" 9 | ) 10 | 11 | var _ interface { 12 | gob.GobDecoder 13 | gob.GobEncoder 14 | } = (*Custom[any, validate.Validator[any]])(nil) 15 | 16 | // GobDecode implements the [gob.GobDecoder] interface. 17 | func (c *Custom[T, V]) GobDecode(data []byte) error { 18 | if len(data) == 0 { 19 | return errors.New("GobDecode: no data") 20 | } 21 | 22 | buf := bytes.NewBuffer(data) 23 | dec := gob.NewDecoder(buf) 24 | 25 | var value T 26 | 27 | if err := dec.Decode(&value); err != nil { 28 | return err 29 | } 30 | 31 | *c = Custom[T, V]{value: value, hasValue: true} 32 | 33 | return nil 34 | } 35 | 36 | // GobEncode implements the [gob.GobEncoder] interface. 37 | func (c Custom[T, V]) GobEncode() ([]byte, error) { 38 | var buf bytes.Buffer 39 | 40 | enc := gob.NewEncoder(&buf) 41 | if err := enc.Encode(c.Get()); err != nil { 42 | return nil, err 43 | } 44 | 45 | return buf.Bytes(), nil 46 | } 47 | -------------------------------------------------------------------------------- /required/json.go: -------------------------------------------------------------------------------- 1 | package required 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/metafates/schema/validate" 7 | ) 8 | 9 | var _ interface { 10 | json.Unmarshaler 11 | json.Marshaler 12 | } = (*Custom[any, validate.Validator[any]])(nil) 13 | 14 | // UnmarshalJSON implements the [json.Unmarshaler] interface. 15 | func (c *Custom[T, V]) UnmarshalJSON(data []byte) error { 16 | var value *T 17 | 18 | if err := json.Unmarshal(data, &value); err != nil { 19 | return err 20 | } 21 | 22 | // validated status will reset here 23 | if value == nil { 24 | *c = Custom[T, V]{} 25 | 26 | return nil 27 | } 28 | 29 | *c = Custom[T, V]{value: *value, hasValue: true} 30 | 31 | return nil 32 | } 33 | 34 | // MarshalJSON implements the [json.Marshaler] interface. 35 | func (c Custom[T, V]) MarshalJSON() ([]byte, error) { 36 | return json.Marshal(c.Get()) 37 | } 38 | -------------------------------------------------------------------------------- /required/required.go: -------------------------------------------------------------------------------- 1 | // Code generated by validators.py; DO NOT EDIT. 2 | 3 | package required 4 | 5 | import ( 6 | "github.com/metafates/schema/constraint" 7 | "github.com/metafates/schema/validate" 8 | "github.com/metafates/schema/validate/charset" 9 | ) 10 | 11 | // Any accepts any value of T. 12 | type Any[T any] = Custom[T, validate.Any[T]] 13 | 14 | // Zero accepts all zero values. 15 | // 16 | // The zero value is: 17 | // - 0 for numeric types, 18 | // - false for the boolean type, and 19 | // - "" (the empty string) for strings. 20 | // 21 | // See [NonZero]. 22 | type Zero[T comparable] = Custom[T, validate.Zero[T]] 23 | 24 | // NonZero accepts all non-zero values. 25 | // 26 | // The zero value is: 27 | // - 0 for numeric types, 28 | // - false for the boolean type, and 29 | // - "" (the empty string) for strings. 30 | // 31 | // See [Zero]. 32 | type NonZero[T comparable] = Custom[T, validate.NonZero[T]] 33 | 34 | // Positive accepts all positive real numbers excluding zero. 35 | // 36 | // See [Positive0] for zero including variant. 37 | type Positive[T constraint.Real] = Custom[T, validate.Positive[T]] 38 | 39 | // Negative accepts all negative real numbers excluding zero. 40 | // 41 | // See [Negative0] for zero including variant. 42 | type Negative[T constraint.Real] = Custom[T, validate.Negative[T]] 43 | 44 | // Positive0 accepts all positive real numbers including zero. 45 | // 46 | // See [Positive] for zero excluding variant. 47 | type Positive0[T constraint.Real] = Custom[T, validate.Positive0[T]] 48 | 49 | // Negative0 accepts all negative real numbers including zero. 50 | // 51 | // See [Negative] for zero excluding variant. 52 | type Negative0[T constraint.Real] = Custom[T, validate.Negative0[T]] 53 | 54 | // Even accepts integers divisible by two. 55 | type Even[T constraint.Integer] = Custom[T, validate.Even[T]] 56 | 57 | // Odd accepts integers not divisible by two. 58 | type Odd[T constraint.Integer] = Custom[T, validate.Odd[T]] 59 | 60 | // Email accepts a single RFC 5322 address, e.g. "Barry Gibbs ". 61 | type Email[T constraint.Text] = Custom[T, validate.Email[T]] 62 | 63 | // URL accepts a single url. 64 | // The url may be relative (a path, without a host) or absolute (starting with a scheme). 65 | // 66 | // See also [HTTPURL]. 67 | type URL[T constraint.Text] = Custom[T, validate.URL[T]] 68 | 69 | // HTTPURL accepts a single http(s) url. 70 | // 71 | // See also [URL]. 72 | type HTTPURL[T constraint.Text] = Custom[T, validate.HTTPURL[T]] 73 | 74 | // IP accepts an IP address. 75 | // The address can be in dotted decimal ("192.0.2.1"), 76 | // IPv6 ("2001:db8::68"), or IPv6 with a scoped addressing zone ("fe80::1cc0:3e8c:119f:c2e1%ens18"). 77 | type IP[T constraint.Text] = Custom[T, validate.IP[T]] 78 | 79 | // IPV4 accepts an IP V4 address (e.g. "192.0.2.1"). 80 | type IPV4[T constraint.Text] = Custom[T, validate.IPV4[T]] 81 | 82 | // IPV6 accepts an IP V6 address, including IPv4-mapped IPv6 addresses. 83 | // The address can be regular IPv6 ("2001:db8::68"), or IPv6 with 84 | // a scoped addressing zone ("fe80::1cc0:3e8c:119f:c2e1%ens18"). 85 | type IPV6[T constraint.Text] = Custom[T, validate.IPV6[T]] 86 | 87 | // MAC accepts an IEEE 802 MAC-48, EUI-48, EUI-64, or a 20-octet IP over InfiniBand link-layer address. 88 | type MAC[T constraint.Text] = Custom[T, validate.MAC[T]] 89 | 90 | // CIDR accepts CIDR notation IP address and prefix length, 91 | // like "192.0.2.0/24" or "2001:db8::/32", as defined in RFC 4632 and RFC 4291. 92 | type CIDR[T constraint.Text] = Custom[T, validate.CIDR[T]] 93 | 94 | // Base64 accepts valid base64 encoded strings. 95 | type Base64[T constraint.Text] = Custom[T, validate.Base64[T]] 96 | 97 | // Charset0 accepts (possibly empty) text which contains only runes acceptable by filter. 98 | // See [Charset] for a non-empty variant. 99 | type Charset0[T constraint.Text, F charset.Filter] = Custom[T, validate.Charset0[T, F]] 100 | 101 | // Charset accepts non-empty text which contains only runes acceptable by filter. 102 | // See also [Charset0]. 103 | type Charset[T constraint.Text, F charset.Filter] = Custom[T, validate.Charset[T, F]] 104 | 105 | // Latitude accepts any number in the range [-90; 90]. 106 | // 107 | // See also [Longitude]. 108 | type Latitude[T constraint.Real] = Custom[T, validate.Latitude[T]] 109 | 110 | // Longitude accepts any number in the range [-180; 180]. 111 | // 112 | // See also [Latitude]. 113 | type Longitude[T constraint.Real] = Custom[T, validate.Longitude[T]] 114 | 115 | // InFuture accepts any time after current timestamp. 116 | // 117 | // See also [InPast]. 118 | type InPast[T constraint.Time] = Custom[T, validate.InPast[T]] 119 | 120 | // InFuture accepts any time after current timestamp. 121 | // 122 | // See also [InPast]. 123 | type InFuture[T constraint.Time] = Custom[T, validate.InFuture[T]] 124 | 125 | // Unique accepts a slice-like of unique values. 126 | // 127 | // See [UniqueSlice] for a slice shortcut. 128 | type Unique[S ~[]T, T comparable] = Custom[S, validate.Unique[S, T]] 129 | 130 | // Unique accepts a slice of unique values. 131 | // 132 | // See [Unique] for a more generic version. 133 | type UniqueSlice[T comparable] = Custom[[]T, validate.UniqueSlice[T]] 134 | 135 | // NonEmpty accepts a non-empty slice-like (len > 0). 136 | // 137 | // See [NonEmptySlice] for a slice shortcut. 138 | type NonEmpty[S ~[]T, T any] = Custom[S, validate.NonEmpty[S, T]] 139 | 140 | // NonEmptySlice accepts a non-empty slice (len > 0). 141 | // 142 | // See [NonEmpty] for a more generic version. 143 | type NonEmptySlice[T comparable] = Custom[[]T, validate.NonEmptySlice[T]] 144 | 145 | // MIME accepts RFC 1521 mime type string. 146 | type MIME[T constraint.Text] = Custom[T, validate.MIME[T]] 147 | 148 | // UUID accepts a properly formatted UUID in one of the following formats: 149 | // - xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx 150 | // - urn:uuid:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx 151 | // - xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx 152 | // - {xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx} 153 | type UUID[T constraint.Text] = Custom[T, validate.UUID[T]] 154 | 155 | // JSON accepts valid json encoded text. 156 | type JSON[T constraint.Text] = Custom[T, validate.JSON[T]] 157 | 158 | // CountryAlpha2 accepts case-insensitive ISO 3166 2-letter country code. 159 | type CountryAlpha2[T constraint.Text] = Custom[T, validate.CountryAlpha2[T]] 160 | 161 | // CountryAlpha3 accepts case-insensitive ISO 3166 3-letter country code. 162 | type CountryAlpha3[T constraint.Text] = Custom[T, validate.CountryAlpha3[T]] 163 | 164 | // CountryAlpha accepts either [CountryAlpha2] or [CountryAlpha3]. 165 | type CountryAlpha[T constraint.Text] = Custom[T, validate.CountryAlpha[T]] 166 | 167 | // CurrencyAlpha accepts case-insensitive ISO 4217 alphabetic currency code. 168 | type CurrencyAlpha[T constraint.Text] = Custom[T, validate.CurrencyAlpha[T]] 169 | 170 | // LangAlpha2 accepts case-insensitive ISO 639 2-letter language code. 171 | type LangAlpha2[T constraint.Text] = Custom[T, validate.LangAlpha2[T]] 172 | 173 | // LangAlpha3 accepts case-insensitive ISO 639 3-letter language code. 174 | type LangAlpha3[T constraint.Text] = Custom[T, validate.LangAlpha3[T]] 175 | 176 | // LangAlpha accepts either [LangAlpha2] or [LangAlpha3]. 177 | type LangAlpha[T constraint.Text] = Custom[T, validate.LangAlpha[T]] 178 | 179 | -------------------------------------------------------------------------------- /required/required_test.go: -------------------------------------------------------------------------------- 1 | package required 2 | 3 | import ( 4 | "encoding/json" 5 | "reflect" 6 | "testing" 7 | 8 | "github.com/metafates/schema/internal/testutil" 9 | ) 10 | 11 | func TestCustom_Parse(t *testing.T) { 12 | for _, tc := range []struct { 13 | name string 14 | value any 15 | wantErr bool 16 | }{ 17 | {name: "valid int", value: 5}, 18 | {name: "valid float", value: 5.2}, 19 | {name: "invalid string", value: "hello", wantErr: true}, 20 | {name: "invalid int", value: -2, wantErr: true}, 21 | {name: "nil", value: nil, wantErr: true}, 22 | } { 23 | t.Run(tc.name, func(t *testing.T) { 24 | var positive Positive[int] 25 | 26 | err := positive.Parse(tc.value) 27 | 28 | if tc.wantErr { 29 | testutil.Error(t, err) 30 | testutil.Equal(t, false, positive.validated) 31 | testutil.Equal(t, false, positive.hasValue) 32 | testutil.Equal(t, 0, positive.value) 33 | testutil.Panic(t, func() { 34 | positive.Get() 35 | }) 36 | } else { 37 | testutil.NoError(t, err) 38 | testutil.Equal(t, true, positive.validated) 39 | testutil.Equal(t, true, positive.hasValue) 40 | testutil.Equal(t, reflect.ValueOf(tc.value).Convert(reflect.TypeFor[int]()).Interface().(int), positive.value) 41 | testutil.NoPanic(t, func() { 42 | positive.Get() 43 | }) 44 | } 45 | }) 46 | } 47 | } 48 | 49 | func TestRequired(t *testing.T) { 50 | t.Run("missing value", func(t *testing.T) { 51 | var foo Any[string] 52 | 53 | testutil.NoError(t, json.Unmarshal([]byte(`null`), &foo)) 54 | 55 | testutil.Equal(t, false, foo.hasValue) 56 | testutil.Equal(t, "", foo.value) 57 | 58 | testutil.Error(t, foo.TypeValidate()) 59 | testutil.Equal(t, false, foo.validated) 60 | 61 | testutil.Panic(t, func() { foo.Get() }) 62 | testutil.Panic(t, func() { foo.MarshalJSON() }) 63 | testutil.Panic(t, func() { foo.MarshalText() }) 64 | testutil.Panic(t, func() { foo.MarshalBinary() }) 65 | testutil.Panic(t, func() { foo.GobEncode() }) 66 | testutil.Panic(t, func() { foo.Value() }) 67 | }) 68 | 69 | t.Run("invalid value", func(t *testing.T) { 70 | var foo Positive[int] 71 | 72 | testutil.NoError(t, json.Unmarshal([]byte(`-24`), &foo)) 73 | 74 | testutil.Equal(t, true, foo.hasValue) 75 | testutil.Equal(t, -24, foo.value) 76 | 77 | testutil.Error(t, foo.TypeValidate()) 78 | testutil.Equal(t, false, foo.validated) 79 | 80 | testutil.Panic(t, func() { foo.Get() }) 81 | testutil.Panic(t, func() { foo.MarshalJSON() }) 82 | testutil.Panic(t, func() { foo.MarshalText() }) 83 | testutil.Panic(t, func() { foo.MarshalBinary() }) 84 | testutil.Panic(t, func() { foo.GobEncode() }) 85 | testutil.Panic(t, func() { foo.Value() }) 86 | }) 87 | 88 | t.Run("nested invalid value", func(t *testing.T) { 89 | type Foo struct { 90 | Field Positive[int] 91 | } 92 | 93 | var foo Any[Foo] 94 | 95 | testutil.NoError(t, json.Unmarshal([]byte(`{"field":-1}`), &foo)) 96 | testutil.Error(t, foo.TypeValidate()) 97 | }) 98 | 99 | t.Run("valid value", func(t *testing.T) { 100 | var foo Positive[int] 101 | 102 | testutil.NoError(t, json.Unmarshal([]byte(`24`), &foo)) 103 | 104 | testutil.Equal(t, true, foo.hasValue) 105 | testutil.Equal(t, 24, foo.value) 106 | 107 | testutil.NoError(t, foo.TypeValidate()) 108 | testutil.Equal(t, true, foo.validated) 109 | 110 | testutil.NoPanic(t, func() { foo.Get() }) 111 | testutil.NoPanic(t, func() { foo.MarshalJSON() }) 112 | testutil.NoPanic(t, func() { foo.MarshalText() }) 113 | testutil.NoPanic(t, func() { foo.MarshalBinary() }) 114 | testutil.NoPanic(t, func() { foo.GobEncode() }) 115 | testutil.NoPanic(t, func() { foo.Value() }) 116 | 117 | t.Run("reuse as invalid", func(t *testing.T) { 118 | testutil.NoError(t, json.Unmarshal([]byte(`-24`), &foo)) 119 | 120 | testutil.Equal(t, true, foo.hasValue) 121 | testutil.Equal(t, -24, foo.value) 122 | 123 | testutil.Error(t, foo.TypeValidate()) 124 | testutil.Equal(t, false, foo.validated) 125 | 126 | testutil.Panic(t, func() { foo.Get() }) 127 | testutil.Panic(t, func() { foo.MarshalJSON() }) 128 | testutil.Panic(t, func() { foo.MarshalText() }) 129 | testutil.Panic(t, func() { foo.MarshalBinary() }) 130 | testutil.Panic(t, func() { foo.GobEncode() }) 131 | testutil.Panic(t, func() { foo.Value() }) 132 | }) 133 | }) 134 | } 135 | -------------------------------------------------------------------------------- /required/sql.go: -------------------------------------------------------------------------------- 1 | package required 2 | 3 | import ( 4 | "database/sql" 5 | "database/sql/driver" 6 | "fmt" 7 | 8 | "github.com/metafates/schema/validate" 9 | ) 10 | 11 | var _ interface { 12 | sql.Scanner 13 | driver.Valuer 14 | } = (*Custom[any, validate.Validator[any]])(nil) 15 | 16 | // Scan implements the [sql.Scanner] interface. 17 | // 18 | // Use [Custom.Parse] instead if you need to construct this value manually. 19 | func (c *Custom[T, V]) Scan(src any) error { 20 | if src == nil { 21 | *c = Custom[T, V]{} 22 | 23 | return nil 24 | } 25 | 26 | var value T 27 | 28 | if scanner, ok := any(&value).(sql.Scanner); ok { 29 | if err := scanner.Scan(src); err != nil { 30 | return err 31 | } 32 | 33 | *c = Custom[T, V]{value: value, hasValue: true} 34 | 35 | return nil 36 | } 37 | 38 | if converted, err := driver.DefaultParameterConverter.ConvertValue(src); err == nil { 39 | if v, ok := converted.(T); ok { 40 | *c = Custom[T, V]{value: v, hasValue: true} 41 | 42 | return nil 43 | } 44 | } 45 | 46 | var nullable sql.Null[T] 47 | 48 | if err := nullable.Scan(src); err != nil { 49 | return err 50 | } 51 | 52 | if nullable.Valid { 53 | *c = Custom[T, V]{value: nullable.V, hasValue: true} 54 | } else { 55 | *c = Custom[T, V]{} 56 | } 57 | 58 | return nil 59 | } 60 | 61 | // Value implements the [driver.Valuer] interface. 62 | // 63 | // Use [Custom.Get] method instead for getting the go value. 64 | func (c Custom[T, V]) Value() (driver.Value, error) { 65 | value, err := driver.DefaultParameterConverter.ConvertValue(c.Get()) 66 | if err != nil { 67 | return nil, fmt.Errorf("convert: %w", err) 68 | } 69 | 70 | return value, nil 71 | } 72 | -------------------------------------------------------------------------------- /required/text.go: -------------------------------------------------------------------------------- 1 | package required 2 | 3 | import ( 4 | "encoding" 5 | 6 | "github.com/metafates/schema/validate" 7 | ) 8 | 9 | var _ interface { 10 | encoding.TextMarshaler 11 | encoding.TextUnmarshaler 12 | } = (*Custom[any, validate.Validator[any]])(nil) 13 | 14 | // UnmarshalText implements the [encoding.TextUnmarshaler] interface. 15 | func (c *Custom[T, V]) UnmarshalText(data []byte) error { 16 | return c.UnmarshalJSON(data) 17 | } 18 | 19 | // MarshalText implements the [encoding.TextMarshaler] interface. 20 | func (c Custom[T, V]) MarshalText() ([]byte, error) { 21 | return c.MarshalJSON() 22 | } 23 | -------------------------------------------------------------------------------- /schema.go: -------------------------------------------------------------------------------- 1 | // Package schema is schema declaration and validation with static types. 2 | // No field tags or code duplication. 3 | // 4 | // Schema is designed to be as developer-friendly as possible. 5 | // The goal is to eliminate duplicative type declarations. 6 | // You declare a schema once and it will be used as both schema and type itself. 7 | // It's easy to compose simpler types into complex data structures. 8 | package schema 9 | 10 | //go:generate python3 validators.py 11 | -------------------------------------------------------------------------------- /validate/charset/charset.go: -------------------------------------------------------------------------------- 1 | // Package charset provides various charset filters to be used in combination with charset validator 2 | package charset 3 | 4 | import ( 5 | "errors" 6 | "fmt" 7 | "unicode" 8 | ) 9 | 10 | // Filter represents charset filter. 11 | type Filter interface { 12 | Filter(r rune) error 13 | } 14 | 15 | type ( 16 | // Any accepts any rune. 17 | Any struct{} 18 | 19 | // ASCII accepts ASCII runes. 20 | ASCII struct{} 21 | 22 | // Graphic wraps [unicode.IsGraphic]. 23 | Graphic struct{} 24 | 25 | // Print wraps [unicode.IsPrint]. 26 | Print struct{} 27 | 28 | // Control wraps [unicode.IsControl]. 29 | Control struct{} 30 | 31 | // Letter wraps [unicode.IsLetter]. 32 | Letter struct{} 33 | 34 | // Mark wraps [unicode.IsMark]. 35 | Mark struct{} 36 | 37 | // Number wraps [unicode.IsNumber]. 38 | Number struct{} 39 | 40 | // Punct wraps [unicode.IsPunct]. 41 | Punct struct{} 42 | 43 | // Space wraps [unicode.IsSpace]. 44 | Space struct{} 45 | 46 | // Symbol wraps [unicode.IsSymbol]. 47 | Symbol struct{} 48 | 49 | // And is a meta filter that combines multiple filters using AND operator. 50 | And[A, B Filter] struct{} 51 | 52 | // Or is a meta filter that combines multiple filters using OR operator. 53 | Or[A, B Filter] struct{} 54 | 55 | // Not is a meta filter that inverts given filter. 56 | Not[F Filter] struct{} 57 | ) 58 | 59 | // Common aliases. 60 | type ( 61 | // ASCIINumber intersects [ASCII] and [Number]. 62 | ASCIINumber = And[ASCII, Number] 63 | 64 | // ASCIIPrint intersects [ASCII] and [Print]. 65 | ASCIIPrint = And[ASCII, Print] 66 | 67 | // ASCIILetter intersects [ASCII] and [Letter]. 68 | ASCIILetter = And[ASCII, Letter] 69 | 70 | // ASCIIPunct intersects [ASCII] and [Punct]. 71 | ASCIIPunct = And[ASCII, Punct] 72 | ) 73 | 74 | func (Any) Filter(rune) error { return nil } 75 | func (ASCII) Filter(r rune) error { return assert(r <= unicode.MaxASCII, "non-ascii character") } 76 | func (Graphic) Filter(r rune) error { return assert(unicode.IsGraphic(r), "non-graphic character") } 77 | func (Print) Filter(r rune) error { return assert(unicode.IsPrint(r), "non-printable character") } 78 | func (Control) Filter(r rune) error { return assert(unicode.IsControl(r), "non-control character") } 79 | func (Letter) Filter(r rune) error { return assert(unicode.IsLetter(r), "non-letter character") } 80 | func (Mark) Filter(r rune) error { return assert(unicode.IsMark(r), "non-mark character") } 81 | func (Number) Filter(r rune) error { return assert(unicode.IsNumber(r), "non-number character") } 82 | 83 | func (Punct) Filter( 84 | r rune, 85 | ) error { 86 | return assert(unicode.IsPunct(r), "non-punctuation character") 87 | } 88 | func (Space) Filter(r rune) error { return assert(unicode.IsSpace(r), "non-space character") } 89 | func (Symbol) Filter(r rune) error { return assert(unicode.IsSymbol(r), "non-symbol character") } 90 | 91 | func (And[A, B]) Filter(r rune) error { 92 | if err := (*new(A)).Filter(r); err != nil { 93 | return err 94 | } 95 | 96 | if err := (*new(B)).Filter(r); err != nil { 97 | return err 98 | } 99 | 100 | return nil 101 | } 102 | 103 | func (Or[A, B]) Filter(r rune) error { 104 | errA := (*new(A)).Filter(r) 105 | if errA == nil { 106 | return nil 107 | } 108 | 109 | errB := (*new(B)).Filter(r) 110 | if errB == nil { 111 | return nil 112 | } 113 | 114 | return errors.Join(errA, errB) 115 | } 116 | 117 | func (Not[F]) Filter(r rune) error { 118 | var f F 119 | 120 | if err := f.Filter(r); err != nil { 121 | //nolint:nilerr 122 | return nil 123 | } 124 | 125 | return errors.New(fmt.Sprint(f)) 126 | } 127 | 128 | func assert(condition bool, msg string) error { 129 | if !condition { 130 | return errors.New(msg) 131 | } 132 | 133 | return nil 134 | } 135 | -------------------------------------------------------------------------------- /validate/charset/charset_test.go: -------------------------------------------------------------------------------- 1 | package charset 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/metafates/schema/internal/testutil" 7 | ) 8 | 9 | func TestFilter(t *testing.T) { 10 | for _, tc := range []struct { 11 | name string 12 | filter Filter 13 | valid, invalid []rune 14 | }{ 15 | { 16 | name: "ascii", 17 | filter: ASCII{}, 18 | valid: []rune{'A'}, 19 | invalid: []rune{'Ж'}, 20 | }, 21 | { 22 | name: "graphic", 23 | filter: Graphic{}, 24 | valid: []rune{'Ж'}, 25 | invalid: []rune{0}, 26 | }, 27 | { 28 | name: "print", 29 | filter: Print{}, 30 | valid: []rune{'A'}, 31 | invalid: []rune{0}, 32 | }, 33 | { 34 | name: "control", 35 | filter: Control{}, 36 | valid: []rune{0}, 37 | invalid: []rune{'A'}, 38 | }, 39 | { 40 | name: "letter", 41 | filter: Letter{}, 42 | valid: []rune{'A'}, 43 | invalid: []rune{'?'}, 44 | }, 45 | { 46 | name: "mark", 47 | filter: Mark{}, 48 | valid: []rune{0x300}, // Combining Grave Accent 49 | invalid: []rune{'?'}, 50 | }, 51 | { 52 | name: "punct", 53 | filter: Punct{}, 54 | valid: []rune{';'}, 55 | invalid: []rune{'A'}, 56 | }, 57 | { 58 | name: "space", 59 | filter: Space{}, 60 | valid: []rune{' '}, 61 | invalid: []rune{'_'}, 62 | }, 63 | { 64 | name: "symbol", 65 | filter: Symbol{}, 66 | valid: []rune{'✨'}, 67 | invalid: []rune{0}, 68 | }, 69 | { 70 | name: "and", 71 | filter: And[Space, Print]{}, 72 | valid: []rune{' '}, 73 | invalid: []rune{0xA0, '8'}, 74 | }, 75 | { 76 | name: "or", 77 | filter: Or[Letter, Number]{}, 78 | valid: []rune{'A', '1'}, 79 | invalid: []rune{' ', '?'}, 80 | }, 81 | { 82 | name: "not", 83 | filter: Not[Number]{}, 84 | valid: []rune{'A', '?'}, 85 | invalid: []rune{'1', '0'}, 86 | }, 87 | } { 88 | t.Run(tc.name, func(t *testing.T) { 89 | t.Run("valid", func(t *testing.T) { 90 | for _, r := range tc.valid { 91 | testutil.NoError(t, tc.filter.Filter(r)) 92 | } 93 | }) 94 | 95 | t.Run("invalid", func(t *testing.T) { 96 | for _, r := range tc.invalid { 97 | testutil.Error(t, tc.filter.Filter(r)) 98 | } 99 | }) 100 | }) 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /validate/error.go: -------------------------------------------------------------------------------- 1 | package validate 2 | 3 | import ( 4 | "errors" 5 | "reflect" 6 | "strings" 7 | ) 8 | 9 | // InvalidValidateError describes an invalid argument passed to [Validate]. 10 | // The argument to [Validate] must be a non-nil pointer. 11 | type InvalidValidateError struct { 12 | Type reflect.Type 13 | } 14 | 15 | func (e InvalidValidateError) Error() string { 16 | if e.Type == nil { 17 | return "Validate(nil)" 18 | } 19 | 20 | if e.Type.Kind() != reflect.Pointer { 21 | return "Validate(non-pointer " + e.Type.String() + ")" 22 | } 23 | 24 | return "Validate(nil " + e.Type.String() + ")" 25 | } 26 | 27 | // ValidationError describes validation error occurred at [Validate]. 28 | type ValidationError struct { 29 | Msg string 30 | Inner error 31 | 32 | path string 33 | } 34 | 35 | // WithPath returns a copy of [ValidationError] with the given path set. 36 | func (e ValidationError) WithPath(path string) ValidationError { 37 | e.path = path 38 | 39 | return e 40 | } 41 | 42 | // Path returns the path to the value which raised this error. 43 | func (e ValidationError) Path() string { 44 | var recursive func(path []string, err error) []string 45 | 46 | recursive = func(path []string, err error) []string { 47 | var validationErr ValidationError 48 | 49 | if !errors.As(err, &validationErr) { 50 | return path 51 | } 52 | 53 | if validationErr.path != "" { 54 | path = append(path, validationErr.path) 55 | } 56 | 57 | return recursive(path, validationErr.Inner) 58 | } 59 | 60 | return strings.Join(recursive(nil, e), "") 61 | } 62 | 63 | func (e ValidationError) Error() string { 64 | return "validate: " + e.error() 65 | } 66 | 67 | func (e ValidationError) Unwrap() error { 68 | return e.Inner 69 | } 70 | 71 | func (e ValidationError) error() string { 72 | segments := make([]string, 0, 3) 73 | 74 | if e.path != "" { 75 | segments = append(segments, e.path) 76 | } 77 | 78 | if e.Msg != "" { 79 | segments = append(segments, e.Msg) 80 | } 81 | 82 | if e.Inner != nil { 83 | var ve ValidationError 84 | 85 | if errors.As(e.Inner, &ve) { 86 | segments = append(segments, ve.error()) 87 | } else { 88 | segments = append(segments, e.Inner.Error()) 89 | } 90 | } 91 | 92 | // path: msg: inner error 93 | return strings.Join(segments, ": ") 94 | } 95 | -------------------------------------------------------------------------------- /validate/impl.go: -------------------------------------------------------------------------------- 1 | package validate 2 | 3 | import ( 4 | "encoding/base64" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "math" 9 | "mime" 10 | "net" 11 | "net/mail" 12 | "net/netip" 13 | "net/url" 14 | "strings" 15 | "time" 16 | 17 | "github.com/metafates/schema/internal/iso" 18 | "github.com/metafates/schema/internal/uuid" 19 | ) 20 | 21 | func (Any[T]) Validate(T) error { 22 | return nil 23 | } 24 | 25 | func (Zero[T]) Validate(value T) error { 26 | var empty T 27 | 28 | if value != empty { 29 | return errors.New("non-zero value") 30 | } 31 | 32 | return nil 33 | } 34 | 35 | func (NonZero[T]) Validate(value T) error { 36 | var empty T 37 | 38 | if value == empty { 39 | return errors.New("zero value") 40 | } 41 | 42 | return nil 43 | } 44 | 45 | func (Positive[T]) Validate(value T) error { 46 | if value < 0 { 47 | return errors.New("negative value") 48 | } 49 | 50 | if value == 0 { 51 | return errors.New("zero value") 52 | } 53 | 54 | return nil 55 | } 56 | 57 | func (Negative[T]) Validate(value T) error { 58 | if value > 0 { 59 | return errors.New("positive value") 60 | } 61 | 62 | if value == 0 { 63 | return errors.New("zero value") 64 | } 65 | 66 | return nil 67 | } 68 | 69 | func (Even[T]) Validate(value T) error { 70 | if value%2 != 0 { 71 | return errors.New("odd value") 72 | } 73 | 74 | return nil 75 | } 76 | 77 | func (Odd[T]) Validate(value T) error { 78 | if value%2 == 0 { 79 | return errors.New("even value") 80 | } 81 | 82 | return nil 83 | } 84 | 85 | func (Email[T]) Validate(value T) error { 86 | _, err := mail.ParseAddress(string(value)) 87 | if err != nil { 88 | return err 89 | } 90 | 91 | return nil 92 | } 93 | 94 | func (URL[T]) Validate(value T) error { 95 | _, err := url.Parse(string(value)) 96 | if err != nil { 97 | return err 98 | } 99 | 100 | return nil 101 | } 102 | 103 | func (HTTPURL[T]) Validate(value T) error { 104 | u, err := url.Parse(string(value)) 105 | if err != nil { 106 | return err 107 | } 108 | 109 | if u.Host == "" { 110 | return errors.New("empty host") 111 | } 112 | 113 | switch u.Scheme { 114 | case "http", "https": 115 | return nil 116 | 117 | default: 118 | return errors.New("non-http(s) scheme") 119 | } 120 | } 121 | 122 | func (IP[T]) Validate(value T) error { 123 | _, err := netip.ParseAddr(string(value)) 124 | if err != nil { 125 | return err 126 | } 127 | 128 | return nil 129 | } 130 | 131 | func (IPV4[T]) Validate(value T) error { 132 | a, err := netip.ParseAddr(string(value)) 133 | if err != nil { 134 | return err 135 | } 136 | 137 | if !a.Is4() { 138 | return errors.New("ipv6 address") 139 | } 140 | 141 | return nil 142 | } 143 | 144 | func (IPV6[T]) Validate(value T) error { 145 | a, err := netip.ParseAddr(string(value)) 146 | if err != nil { 147 | return err 148 | } 149 | 150 | if !a.Is6() { 151 | return errors.New("ipv6 address") 152 | } 153 | 154 | return nil 155 | } 156 | 157 | func (MAC[T]) Validate(value T) error { 158 | _, err := net.ParseMAC(string(value)) 159 | if err != nil { 160 | return err 161 | } 162 | 163 | return nil 164 | } 165 | 166 | func (CIDR[T]) Validate(value T) error { 167 | _, _, err := net.ParseCIDR(string(value)) 168 | if err != nil { 169 | return err 170 | } 171 | 172 | return nil 173 | } 174 | 175 | func (Base64[T]) Validate(value T) error { 176 | // TODO: implement it without allocating buffer and converting to string 177 | _, err := base64.StdEncoding.DecodeString(string(value)) 178 | if err != nil { 179 | return err 180 | } 181 | 182 | return nil 183 | } 184 | 185 | func (Charset0[T, F]) Validate(value T) error { 186 | var f F 187 | 188 | for _, r := range string(value) { 189 | if err := f.Filter(r); err != nil { 190 | return err 191 | } 192 | } 193 | 194 | return nil 195 | } 196 | 197 | func (Charset[T, F]) Validate(value T) error { 198 | if len(value) == 0 { 199 | return errors.New("empty text") 200 | } 201 | 202 | return Charset0[T, F]{}.Validate(value) 203 | } 204 | 205 | func (Latitude[T]) Validate(value T) error { 206 | abs := math.Abs(float64(value)) 207 | 208 | if abs > 90 { 209 | return errors.New("invalid latitude") 210 | } 211 | 212 | return nil 213 | } 214 | 215 | func (Longitude[T]) Validate(value T) error { 216 | abs := math.Abs(float64(value)) 217 | 218 | if abs > 180 { 219 | return errors.New("invalid longitude") 220 | } 221 | 222 | return nil 223 | } 224 | 225 | func (InPast[T]) Validate(value T) error { 226 | if value.Compare(time.Now()) > 0 { 227 | return errors.New("time is not in the past") 228 | } 229 | 230 | return nil 231 | } 232 | 233 | func (InFuture[T]) Validate(value T) error { 234 | if value.Compare(time.Now()) < 0 { 235 | return errors.New("time is not in the future") 236 | } 237 | 238 | return nil 239 | } 240 | 241 | func (Unique[S, T]) Validate(value S) error { 242 | visited := make(map[T]struct{}) 243 | 244 | for _, v := range value { 245 | if _, ok := visited[v]; ok { 246 | return errors.New("duplicate value found") 247 | } 248 | 249 | visited[v] = struct{}{} 250 | } 251 | 252 | return nil 253 | } 254 | 255 | func (NonEmpty[S, T]) Validate(value S) error { 256 | if len(value) == 0 { 257 | return errors.New("empty slice") 258 | } 259 | 260 | return nil 261 | } 262 | 263 | func (MIME[T]) Validate(value T) error { 264 | _, _, err := mime.ParseMediaType(string(value)) 265 | if err != nil { 266 | return err 267 | } 268 | 269 | return nil 270 | } 271 | 272 | func (UUID[T]) Validate(value T) error { 273 | // converting to bytes is cheaper than vice versa 274 | if err := uuid.Validate(string(value)); err != nil { 275 | return err 276 | } 277 | 278 | return nil 279 | } 280 | 281 | func (JSON[T]) Validate(value T) error { 282 | if !json.Valid([]byte(string(value))) { 283 | return errors.New("invalid json") 284 | } 285 | 286 | return nil 287 | } 288 | 289 | func (CountryAlpha2[T]) Validate(value T) error { 290 | v := strings.ToLower(string(value)) 291 | 292 | if _, ok := iso.CountryAlpha2[v]; !ok { 293 | return errors.New("unknown 2-letter country code") 294 | } 295 | 296 | return nil 297 | } 298 | 299 | func (CountryAlpha3[T]) Validate(value T) error { 300 | v := strings.ToLower(string(value)) 301 | 302 | if _, ok := iso.CountryAlpha3[v]; !ok { 303 | return errors.New("unknown 3-letter country code") 304 | } 305 | 306 | return nil 307 | } 308 | 309 | func (CurrencyAlpha[T]) Validate(value T) error { 310 | v := strings.ToLower(string(value)) 311 | 312 | if _, ok := iso.CurrencyAlpha[v]; !ok { 313 | return errors.New("unknown currency alphabetic code") 314 | } 315 | 316 | return nil 317 | } 318 | 319 | func (LangAlpha2[T]) Validate(value T) error { 320 | v := strings.ToLower(string(value)) 321 | 322 | if _, ok := iso.LanguageAlpha2[v]; !ok { 323 | return errors.New("unknown 2-letter language code") 324 | } 325 | 326 | return nil 327 | } 328 | 329 | func (LangAlpha3[T]) Validate(value T) error { 330 | v := strings.ToLower(string(value)) 331 | 332 | if _, ok := iso.LanguageAlpha3[v]; !ok { 333 | return errors.New("unknown 3-letter language code") 334 | } 335 | 336 | return nil 337 | } 338 | 339 | func (And[T, A, B]) Validate(value T) error { 340 | if err := (*new(A)).Validate(value); err != nil { 341 | return err 342 | } 343 | 344 | if err := (*new(B)).Validate(value); err != nil { 345 | return err 346 | } 347 | 348 | return nil 349 | } 350 | 351 | func (Or[T, A, B]) Validate(value T) error { 352 | errA := (*new(A)).Validate(value) 353 | if errA == nil { 354 | return nil 355 | } 356 | 357 | errB := (*new(B)).Validate(value) 358 | if errB == nil { 359 | return nil 360 | } 361 | 362 | return errors.Join(errA, errB) 363 | } 364 | 365 | func (Not[T, V]) Validate(value T) error { 366 | var v V 367 | 368 | //nolint:nilerr 369 | if err := v.Validate(value); err != nil { 370 | return nil 371 | } 372 | 373 | return errors.New(fmt.Sprint(v)) 374 | } 375 | -------------------------------------------------------------------------------- /validate/validate.go: -------------------------------------------------------------------------------- 1 | // Package validate provides type enforced validators. 2 | package validate 3 | 4 | import ( 5 | "reflect" 6 | 7 | "github.com/metafates/schema/internal/reflectwalk" 8 | ) 9 | 10 | type ( 11 | // Validator is an interface that validators must implement. 12 | // It's a special empty (struct{}) type that is invoked in a form of (*new(V)).Validate(...). 13 | // Therefore it should not depend on inner state (fields). 14 | Validator[T any] interface { 15 | Validate(value T) error 16 | } 17 | 18 | // TypeValidateable is an interface for types that can validate their types. 19 | // This is used by required and optional fields so that they can validate if contained values 20 | // satisfy the schema enforced by [Validator] backed type. 21 | // 22 | // TL;DR: do not implement nor use this method directly (codegen is exception). 23 | // 24 | // See [Validateable] interface if you want to implement custom validation. 25 | TypeValidateable interface { 26 | TypeValidate() error 27 | } 28 | 29 | // Validateable is an interface for types that can perform validation logic after 30 | // type validation (by [TypeValidateable]) has been called without errors. 31 | // 32 | // Primary usecase is custom cross-field validation. E.g. if X is true then Y cannot be empty. 33 | Validateable interface { 34 | Validate() error 35 | } 36 | ) 37 | 38 | // Validate checks if the provided value can be validated and reports any validation errors. 39 | // 40 | // The validation process follows these steps: 41 | // 1. If v implements both [TypeValidateable] and [Validateable] interfaces, its [TypeValidateable.TypeValidate] 42 | // method is called, followed by its [Validateable.Validate] method. 43 | // 2. If v only implements the [TypeValidateable] interface, its [TypeValidateable.TypeValidate] method is called. 44 | // 3. Otherwise, Validate traverses all fields in the struct pointed to by v, applying the same validation 45 | // logic to each field. 46 | // 47 | // A [ValidationError] is returned if any validation fails during any step. 48 | // 49 | // If v is nil or not a pointer, Validate returns an [InvalidValidateError]. 50 | func Validate(v any) error { 51 | switch v := v.(type) { 52 | case interface { 53 | TypeValidateable 54 | Validateable 55 | }: 56 | if err := v.TypeValidate(); err != nil { 57 | return ValidationError{Inner: err} 58 | } 59 | 60 | if err := v.Validate(); err != nil { 61 | return ValidationError{Inner: err} 62 | } 63 | 64 | return nil 65 | 66 | case TypeValidateable: 67 | if err := v.TypeValidate(); err != nil { 68 | return ValidationError{Inner: err} 69 | } 70 | 71 | return nil 72 | } 73 | 74 | // same thing [json.Unmarshal] does 75 | rv := reflect.ValueOf(v) 76 | if rv.Kind() != reflect.Pointer || rv.IsNil() { 77 | return &InvalidValidateError{Type: reflect.TypeOf(v)} 78 | } 79 | 80 | return validate(v) 81 | } 82 | 83 | func validate(v any) error { 84 | var postValidate []func() error 85 | 86 | err := reflectwalk.WalkFields(v, func(path string, reflectValue reflect.Value) error { 87 | if reflectValue.CanAddr() { 88 | reflectValue = reflectValue.Addr() 89 | } 90 | 91 | value := reflectValue.Interface() 92 | 93 | if value, ok := value.(TypeValidateable); ok { 94 | if err := value.TypeValidate(); err != nil { 95 | return ValidationError{Inner: err, path: path} 96 | } 97 | } 98 | 99 | if value, ok := value.(Validateable); ok { 100 | postValidate = append(postValidate, func() error { 101 | if err := value.Validate(); err != nil { 102 | return ValidationError{Inner: err, path: path} 103 | } 104 | 105 | return nil 106 | }) 107 | } 108 | 109 | return nil 110 | }) 111 | if err != nil { 112 | return err 113 | } 114 | 115 | for _, f := range postValidate { 116 | if err := f(); err != nil { 117 | return err 118 | } 119 | } 120 | 121 | return nil 122 | } 123 | -------------------------------------------------------------------------------- /validate/validators.go: -------------------------------------------------------------------------------- 1 | // Code generated by validators.py; DO NOT EDIT. 2 | 3 | package validate 4 | 5 | import ( 6 | "github.com/metafates/schema/constraint" 7 | "github.com/metafates/schema/validate/charset" 8 | ) 9 | 10 | // Any accepts any value of T. 11 | type Any[T any] struct{} 12 | 13 | // Zero accepts all zero values. 14 | // 15 | // The zero value is: 16 | // - 0 for numeric types, 17 | // - false for the boolean type, and 18 | // - "" (the empty string) for strings. 19 | // 20 | // See [NonZero]. 21 | type Zero[T comparable] struct{} 22 | 23 | // NonZero accepts all non-zero values. 24 | // 25 | // The zero value is: 26 | // - 0 for numeric types, 27 | // - false for the boolean type, and 28 | // - "" (the empty string) for strings. 29 | // 30 | // See [Zero]. 31 | type NonZero[T comparable] struct{} 32 | 33 | // Positive accepts all positive real numbers excluding zero. 34 | // 35 | // See [Positive0] for zero including variant. 36 | type Positive[T constraint.Real] struct{} 37 | 38 | // Negative accepts all negative real numbers excluding zero. 39 | // 40 | // See [Negative0] for zero including variant. 41 | type Negative[T constraint.Real] struct{} 42 | 43 | // Positive0 accepts all positive real numbers including zero. 44 | // 45 | // See [Positive] for zero excluding variant. 46 | type Positive0[T constraint.Real] struct{ 47 | Or[T, Positive[T], Zero[T]] 48 | } 49 | 50 | // Negative0 accepts all negative real numbers including zero. 51 | // 52 | // See [Negative] for zero excluding variant. 53 | type Negative0[T constraint.Real] struct{ 54 | Or[T, Negative[T], Zero[T]] 55 | } 56 | 57 | // Even accepts integers divisible by two. 58 | type Even[T constraint.Integer] struct{} 59 | 60 | // Odd accepts integers not divisible by two. 61 | type Odd[T constraint.Integer] struct{} 62 | 63 | // Email accepts a single RFC 5322 address, e.g. "Barry Gibbs ". 64 | type Email[T constraint.Text] struct{} 65 | 66 | // URL accepts a single url. 67 | // The url may be relative (a path, without a host) or absolute (starting with a scheme). 68 | // 69 | // See also [HTTPURL]. 70 | type URL[T constraint.Text] struct{} 71 | 72 | // HTTPURL accepts a single http(s) url. 73 | // 74 | // See also [URL]. 75 | type HTTPURL[T constraint.Text] struct{} 76 | 77 | // IP accepts an IP address. 78 | // The address can be in dotted decimal ("192.0.2.1"), 79 | // IPv6 ("2001:db8::68"), or IPv6 with a scoped addressing zone ("fe80::1cc0:3e8c:119f:c2e1%ens18"). 80 | type IP[T constraint.Text] struct{} 81 | 82 | // IPV4 accepts an IP V4 address (e.g. "192.0.2.1"). 83 | type IPV4[T constraint.Text] struct{} 84 | 85 | // IPV6 accepts an IP V6 address, including IPv4-mapped IPv6 addresses. 86 | // The address can be regular IPv6 ("2001:db8::68"), or IPv6 with 87 | // a scoped addressing zone ("fe80::1cc0:3e8c:119f:c2e1%ens18"). 88 | type IPV6[T constraint.Text] struct{} 89 | 90 | // MAC accepts an IEEE 802 MAC-48, EUI-48, EUI-64, or a 20-octet IP over InfiniBand link-layer address. 91 | type MAC[T constraint.Text] struct{} 92 | 93 | // CIDR accepts CIDR notation IP address and prefix length, 94 | // like "192.0.2.0/24" or "2001:db8::/32", as defined in RFC 4632 and RFC 4291. 95 | type CIDR[T constraint.Text] struct{} 96 | 97 | // Base64 accepts valid base64 encoded strings. 98 | type Base64[T constraint.Text] struct{} 99 | 100 | // Charset0 accepts (possibly empty) text which contains only runes acceptable by filter. 101 | // See [Charset] for a non-empty variant. 102 | type Charset0[T constraint.Text, F charset.Filter] struct{} 103 | 104 | // Charset accepts non-empty text which contains only runes acceptable by filter. 105 | // See also [Charset0]. 106 | type Charset[T constraint.Text, F charset.Filter] struct{} 107 | 108 | // Latitude accepts any number in the range [-90; 90]. 109 | // 110 | // See also [Longitude]. 111 | type Latitude[T constraint.Real] struct{} 112 | 113 | // Longitude accepts any number in the range [-180; 180]. 114 | // 115 | // See also [Latitude]. 116 | type Longitude[T constraint.Real] struct{} 117 | 118 | // InFuture accepts any time after current timestamp. 119 | // 120 | // See also [InPast]. 121 | type InPast[T constraint.Time] struct{} 122 | 123 | // InFuture accepts any time after current timestamp. 124 | // 125 | // See also [InPast]. 126 | type InFuture[T constraint.Time] struct{} 127 | 128 | // Unique accepts a slice-like of unique values. 129 | // 130 | // See [UniqueSlice] for a slice shortcut. 131 | type Unique[S ~[]T, T comparable] struct{} 132 | 133 | // Unique accepts a slice of unique values. 134 | // 135 | // See [Unique] for a more generic version. 136 | type UniqueSlice[T comparable] struct{ 137 | Unique[[]T, T] 138 | } 139 | 140 | // NonEmpty accepts a non-empty slice-like (len > 0). 141 | // 142 | // See [NonEmptySlice] for a slice shortcut. 143 | type NonEmpty[S ~[]T, T any] struct{} 144 | 145 | // NonEmptySlice accepts a non-empty slice (len > 0). 146 | // 147 | // See [NonEmpty] for a more generic version. 148 | type NonEmptySlice[T comparable] struct{ 149 | NonEmpty[[]T, T] 150 | } 151 | 152 | // MIME accepts RFC 1521 mime type string. 153 | type MIME[T constraint.Text] struct{} 154 | 155 | // UUID accepts a properly formatted UUID in one of the following formats: 156 | // - xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx 157 | // - urn:uuid:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx 158 | // - xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx 159 | // - {xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx} 160 | type UUID[T constraint.Text] struct{} 161 | 162 | // JSON accepts valid json encoded text. 163 | type JSON[T constraint.Text] struct{} 164 | 165 | // CountryAlpha2 accepts case-insensitive ISO 3166 2-letter country code. 166 | type CountryAlpha2[T constraint.Text] struct{} 167 | 168 | // CountryAlpha3 accepts case-insensitive ISO 3166 3-letter country code. 169 | type CountryAlpha3[T constraint.Text] struct{} 170 | 171 | // CountryAlpha accepts either [CountryAlpha2] or [CountryAlpha3]. 172 | type CountryAlpha[T constraint.Text] struct{ 173 | Or[T, CountryAlpha2[T], CountryAlpha3[T]] 174 | } 175 | 176 | // CurrencyAlpha accepts case-insensitive ISO 4217 alphabetic currency code. 177 | type CurrencyAlpha[T constraint.Text] struct{} 178 | 179 | // LangAlpha2 accepts case-insensitive ISO 639 2-letter language code. 180 | type LangAlpha2[T constraint.Text] struct{} 181 | 182 | // LangAlpha3 accepts case-insensitive ISO 639 3-letter language code. 183 | type LangAlpha3[T constraint.Text] struct{} 184 | 185 | // LangAlpha accepts either [LangAlpha2] or [LangAlpha3]. 186 | type LangAlpha[T constraint.Text] struct{ 187 | Or[T, LangAlpha2[T], LangAlpha3[T]] 188 | } 189 | 190 | // And is a meta validator that combines other validators with AND operator. 191 | // Validators are called in the same order as specified by type parameters. 192 | // 193 | // See also [Or], [Not]. 194 | type And[T any, A Validator[T], B Validator[T]] struct{} 195 | 196 | // Or is a meta validator that combines other validators with OR operator. 197 | // Validators are called in the same order as type parameters. 198 | // 199 | // See also [And], [Not]. 200 | type Or[T any, A Validator[T], B Validator[T]] struct{} 201 | 202 | // Not is a meta validator that inverts given validator. 203 | // 204 | // See also [And], [Or]. 205 | type Not[T any, V Validator[T]] struct{} 206 | 207 | -------------------------------------------------------------------------------- /validators.md: -------------------------------------------------------------------------------- 1 | # Validators 2 | 3 | This table features all available validators. 4 | 5 | | Name | Description | 6 | | ---- | ----------- | 7 | | `Any[T]` | Any accepts any value of T. | 8 | | `Zero[T]` | Zero accepts all zero values.

The zero value is:
- 0 for numeric types,
- false for the boolean type, and
- "" (the empty string) for strings.

See [NonZero]. | 9 | | `NonZero[T]` | NonZero accepts all non-zero values.

The zero value is:
- 0 for numeric types,
- false for the boolean type, and
- "" (the empty string) for strings.

See [Zero]. | 10 | | `Positive[T]` | Positive accepts all positive real numbers excluding zero.

See [Positive0] for zero including variant. | 11 | | `Negative[T]` | Negative accepts all negative real numbers excluding zero.

See [Negative0] for zero including variant. | 12 | | `Positive0[T]` | Positive0 accepts all positive real numbers including zero.

See [Positive] for zero excluding variant. | 13 | | `Negative0[T]` | Negative0 accepts all negative real numbers including zero.

See [Negative] for zero excluding variant. | 14 | | `Even[T]` | Even accepts integers divisible by two. | 15 | | `Odd[T]` | Odd accepts integers not divisible by two. | 16 | | `Email[T]` | Email accepts a single RFC 5322 address, e.g. "Barry Gibbs ". | 17 | | `URL[T]` | URL accepts a single url.
The url may be relative (a path, without a host) or absolute (starting with a scheme).

See also [HTTPURL]. | 18 | | `HTTPURL[T]` | HTTPURL accepts a single http(s) url.

See also [URL]. | 19 | | `IP[T]` | IP accepts an IP address.
The address can be in dotted decimal ("192.0.2.1"),
IPv6 ("2001:db8::68"), or IPv6 with a scoped addressing zone ("fe80::1cc0:3e8c:119f:c2e1%ens18"). | 20 | | `IPV4[T]` | IPV4 accepts an IP V4 address (e.g. "192.0.2.1"). | 21 | | `IPV6[T]` | IPV6 accepts an IP V6 address, including IPv4-mapped IPv6 addresses.
The address can be regular IPv6 ("2001:db8::68"), or IPv6 with
a scoped addressing zone ("fe80::1cc0:3e8c:119f:c2e1%ens18"). | 22 | | `MAC[T]` | MAC accepts an IEEE 802 MAC-48, EUI-48, EUI-64, or a 20-octet IP over InfiniBand link-layer address. | 23 | | `CIDR[T]` | CIDR accepts CIDR notation IP address and prefix length,
like "192.0.2.0/24" or "2001:db8::/32", as defined in RFC 4632 and RFC 4291. | 24 | | `Base64[T]` | Base64 accepts valid base64 encoded strings. | 25 | | `Charset0[T, F]` | Charset0 accepts (possibly empty) text which contains only runes acceptable by filter.
See [Charset] for a non-empty variant. | 26 | | `Charset[T, F]` | Charset accepts non-empty text which contains only runes acceptable by filter.
See also [Charset0]. | 27 | | `Latitude[T]` | Latitude accepts any number in the range [-90; 90].

See also [Longitude]. | 28 | | `Longitude[T]` | Longitude accepts any number in the range [-180; 180].

See also [Latitude]. | 29 | | `InPast[T]` | InFuture accepts any time after current timestamp.

See also [InPast]. | 30 | | `InFuture[T]` | InFuture accepts any time after current timestamp.

See also [InPast]. | 31 | | `Unique[S, T]` | Unique accepts a slice-like of unique values.

See [UniqueSlice] for a slice shortcut. | 32 | | `UniqueSlice[T]` | Unique accepts a slice of unique values.

See [Unique] for a more generic version. | 33 | | `NonEmpty[S, T]` | NonEmpty accepts a non-empty slice-like (len > 0).

See [NonEmptySlice] for a slice shortcut. | 34 | | `NonEmptySlice[T]` | NonEmptySlice accepts a non-empty slice (len > 0).

See [NonEmpty] for a more generic version. | 35 | | `MIME[T]` | MIME accepts RFC 1521 mime type string. | 36 | | `UUID[T]` | UUID accepts a properly formatted UUID in one of the following formats:
- xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
- urn:uuid:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
- xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
- {xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx} | 37 | | `JSON[T]` | JSON accepts valid json encoded text. | 38 | | `CountryAlpha2[T]` | CountryAlpha2 accepts case-insensitive ISO 3166 2-letter country code. | 39 | | `CountryAlpha3[T]` | CountryAlpha3 accepts case-insensitive ISO 3166 3-letter country code. | 40 | | `CountryAlpha[T]` | CountryAlpha accepts either [CountryAlpha2] or [CountryAlpha3]. | 41 | | `CurrencyAlpha[T]` | CurrencyAlpha accepts case-insensitive ISO 4217 alphabetic currency code. | 42 | | `LangAlpha2[T]` | LangAlpha2 accepts case-insensitive ISO 639 2-letter language code. | 43 | | `LangAlpha3[T]` | LangAlpha3 accepts case-insensitive ISO 639 3-letter language code. | 44 | | `LangAlpha[T]` | LangAlpha accepts either [LangAlpha2] or [LangAlpha3]. | 45 | | `And[T, A, B]` | And is a meta validator that combines other validators with AND operator.
Validators are called in the same order as specified by type parameters.

See also [Or], [Not]. | 46 | | `Or[T, A, B]` | Or is a meta validator that combines other validators with OR operator.
Validators are called in the same order as type parameters.

See also [And], [Not]. | 47 | | `Not[T, V]` | Not is a meta validator that inverts given validator.

See also [And], [Or]. | 48 | -------------------------------------------------------------------------------- /validators.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from typing import Optional, TYPE_CHECKING, Protocol 5 | from pathlib import Path 6 | import tomllib 7 | 8 | if TYPE_CHECKING: 9 | from _typeshed import SupportsWrite 10 | 11 | 12 | @dataclass 13 | class Import: 14 | path: str 15 | pkg: str 16 | 17 | 18 | @dataclass 19 | class Type: 20 | name: str 21 | constraint: str 22 | 23 | 24 | @dataclass 25 | class Validator: 26 | name: str 27 | internal: bool 28 | desc: str 29 | types: list[Type] 30 | embed: Optional[str] 31 | aliased: Optional[str] 32 | 33 | 34 | @dataclass 35 | class Data: 36 | validators: list[Validator] 37 | imports: set[str] 38 | 39 | 40 | def read(path: str) -> Data: 41 | with open(path, "rb") as f: 42 | data = tomllib.load(f) 43 | 44 | imports_registry: dict[str, Import] = {} 45 | 46 | for import_ in data["imports"]: 47 | pkg = import_["pkg"] 48 | path = import_["path"] 49 | 50 | imports_registry[pkg] = Import(path=path, pkg=pkg) 51 | 52 | imports: set[str] = set() 53 | 54 | validators: list[Validator] = [] 55 | 56 | for entry in data["validators"]: 57 | types: list[Type] = [] 58 | 59 | for type_ in entry["types"]: 60 | validator_type = Type(name=type_["name"], constraint=type_["constraint"]) 61 | 62 | types.append(validator_type) 63 | 64 | if "." in validator_type.constraint: 65 | pkg = validator_type.constraint.split(".")[0] 66 | 67 | if pkg not in imports_registry: 68 | raise Exception( 69 | f"unknown package for constraint: {validator_type.constraint}" 70 | ) 71 | 72 | imports.add(imports_registry[pkg].path) 73 | 74 | validator = Validator( 75 | name=entry["name"], 76 | internal=entry.get("internal", False), 77 | desc=entry["desc"], 78 | types=types, 79 | embed=entry.get("embed"), 80 | aliased=entry.get("aliased"), 81 | ) 82 | 83 | validators.append(validator) 84 | 85 | return Data(validators=validators, imports=imports) 86 | 87 | 88 | class P(Protocol): 89 | def __call__(self, *args: object) -> None: ... 90 | 91 | 92 | def make_p(file: SupportsWrite[str]) -> P: 93 | def p(*s: object): 94 | print(*s, file=file) 95 | 96 | return p 97 | 98 | 99 | def comment(s: str) -> str: 100 | commented_lines: list[str] = [] 101 | 102 | for line in s.splitlines(): 103 | commented_lines.append(f"// {line.rstrip()}".strip()) 104 | 105 | return "\n".join(commented_lines) 106 | 107 | 108 | PREAMBLE = "// Code generated by validators.py; DO NOT EDIT." 109 | 110 | 111 | def generate_imports(file: SupportsWrite[str], imports: set[str]): 112 | if not len(imports): 113 | return 114 | 115 | p = make_p(file) 116 | p("import (") 117 | 118 | for path in sorted(imports): 119 | p(f'\t"{path}"') 120 | 121 | p(")") 122 | p() 123 | 124 | 125 | def generate_validators(file: SupportsWrite[str], data: Data): 126 | p = make_p(file) 127 | 128 | p(PREAMBLE) 129 | p() 130 | p("package validate") 131 | p() 132 | 133 | generate_imports(file, data.imports) 134 | 135 | for v in data.validators: 136 | types_str = "" 137 | types = list(map(lambda t: f"{t.name} {t.constraint}", v.types)) 138 | 139 | if len(types): 140 | types_str = f"[{', '.join(types)}]" 141 | 142 | embed = "" 143 | 144 | if v.embed: 145 | embed = f"\n\t{v.embed}\n" 146 | 147 | desc = comment(v.desc) 148 | 149 | if desc: 150 | p(desc) 151 | 152 | p(f"type {v.name}{types_str} struct{{{embed}}}") 153 | p() 154 | 155 | 156 | def generate_aliases(file: SupportsWrite[str], data: Data, pkg: str): 157 | p = make_p(file) 158 | 159 | p(PREAMBLE) 160 | p() 161 | p(f"package {pkg}") 162 | p() 163 | 164 | imports = data.imports.copy() 165 | imports.add("github.com/metafates/schema/validate") 166 | 167 | generate_imports(file, imports) 168 | 169 | for v in filter(lambda v: not v.internal, data.validators): 170 | types_str = "" 171 | types = list(map(lambda t: f"{t.name} {t.constraint}", v.types)) 172 | 173 | if len(types): 174 | types_str = f"[{', '.join(types)}]" 175 | 176 | desc = comment(v.desc) 177 | 178 | if desc: 179 | p(desc) 180 | 181 | aliased = v.aliased 182 | if not aliased: 183 | types = list(map(lambda t: t.name, v.types)) 184 | aliased = f"Custom[{types[0]}, validate.{v.name}[{', '.join(types)}]]" 185 | 186 | p(f"type {v.name}{types_str} = {aliased}") 187 | p() 188 | 189 | 190 | def generate_markdown(file: SupportsWrite[str], data: Data): 191 | p = make_p(file) 192 | 193 | p("# Validators") 194 | p("") 195 | p("This table features all available validators.") 196 | p("") 197 | p("| Name | Description |") 198 | p("| ---- | ----------- |") 199 | for v in data.validators: 200 | desc = "
".join(v.desc.splitlines()) 201 | types = list(map(lambda t: t.name, v.types)) 202 | 203 | p(f"| `{v.name}[{', '.join(types)}]` | {desc} |") 204 | 205 | 206 | def main(): 207 | data = read("validators.toml") 208 | 209 | with Path("validate").joinpath("validators.go").open("w+") as out: 210 | generate_validators(out, data) 211 | 212 | with Path("required").joinpath("required.go").open("w+") as out: 213 | generate_aliases(out, data, pkg="required") 214 | 215 | with Path("optional").joinpath("optional.go").open("w+") as out: 216 | generate_aliases(out, data, pkg="optional") 217 | 218 | with Path("validators.md").open("w+") as out: 219 | generate_markdown(out, data) 220 | 221 | 222 | if __name__ == "__main__": 223 | main() 224 | --------------------------------------------------------------------------------