@nxgt/typespec 0.9.2 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/README.md +71 -14
  2. package/docs/README.md +2 -0
  3. package/docs/guide/auth.md +1 -1
  4. package/docs/guide/columns.md +29 -15
  5. package/docs/guide/errors.md +1 -1
  6. package/docs/guide/getting-started.md +16 -7
  7. package/docs/guide/headers.md +2 -2
  8. package/docs/guide/operation-ids.md +1 -1
  9. package/docs/guide/requests.md +145 -0
  10. package/docs/guide/scalars.md +288 -0
  11. package/docs/roadmap.md +22 -0
  12. package/docs/troubleshooting.md +95 -3
  13. package/lib/main.tsp +1 -0
  14. package/lib/requests.tsp +36 -0
  15. package/lib/scalars/color/hex-color-code.tsp +17 -0
  16. package/lib/scalars/color/hsl.tsp +18 -0
  17. package/lib/scalars/color/hsla.tsp +18 -0
  18. package/lib/scalars/color/rgb.tsp +18 -0
  19. package/lib/scalars/color/rgba.tsp +18 -0
  20. package/lib/scalars/color.tsp +6 -0
  21. package/lib/scalars/date-time/date-time.tsp +19 -0
  22. package/lib/scalars/date-time/date.tsp +17 -0
  23. package/lib/scalars/date-time/iso-duration.tsp +17 -0
  24. package/lib/scalars/date-time/local-date-time.tsp +15 -0
  25. package/lib/scalars/date-time/local-time.tsp +15 -0
  26. package/lib/scalars/date-time/time-zone.tsp +18 -0
  27. package/lib/scalars/date-time/time.tsp +17 -0
  28. package/lib/scalars/date-time/timestamp.tsp +15 -0
  29. package/lib/scalars/date-time/utc-offset.tsp +15 -0
  30. package/lib/scalars/date-time.tsp +10 -0
  31. package/lib/scalars/encoding/base64-url.tsp +17 -0
  32. package/lib/scalars/encoding/base64.tsp +18 -0
  33. package/lib/scalars/encoding/hexadecimal.tsp +15 -0
  34. package/lib/scalars/encoding/jwt.tsp +19 -0
  35. package/lib/scalars/encoding/sha256.tsp +16 -0
  36. package/lib/scalars/encoding/sha512.tsp +16 -0
  37. package/lib/scalars/encoding.tsp +7 -0
  38. package/lib/scalars/finance/currency.tsp +17 -0
  39. package/lib/scalars/finance/iban.tsp +19 -0
  40. package/lib/scalars/finance.tsp +3 -0
  41. package/lib/scalars/geo/latitude.tsp +14 -0
  42. package/lib/scalars/geo/longitude.tsp +14 -0
  43. package/lib/scalars/geo.tsp +3 -0
  44. package/lib/scalars/identifier/cuid2.tsp +17 -0
  45. package/lib/scalars/identifier/guid.tsp +12 -0
  46. package/lib/scalars/identifier/isbn.tsp +18 -0
  47. package/lib/scalars/identifier/ksuid.tsp +18 -0
  48. package/lib/scalars/identifier/nano-id.tsp +16 -0
  49. package/lib/scalars/identifier/object-id.tsp +16 -0
  50. package/lib/scalars/identifier/sem-ver.tsp +17 -0
  51. package/lib/scalars/identifier/ulid.tsp +17 -0
  52. package/lib/scalars/identifier/uuid-v4.tsp +16 -0
  53. package/lib/scalars/identifier/uuid-v7.tsp +16 -0
  54. package/lib/scalars/identifier/uuid.tsp +18 -0
  55. package/lib/scalars/identifier/xid.tsp +17 -0
  56. package/lib/scalars/identifier.tsp +13 -0
  57. package/lib/scalars/locale/country-code.tsp +17 -0
  58. package/lib/scalars/locale/locale.tsp +20 -0
  59. package/lib/scalars/locale.tsp +3 -0
  60. package/lib/scalars/network/cidr-v4.tsp +19 -0
  61. package/lib/scalars/network/cidr-v6.tsp +18 -0
  62. package/lib/scalars/network/email-address.tsp +20 -0
  63. package/lib/scalars/network/hostname.tsp +21 -0
  64. package/lib/scalars/network/http-url.tsp +21 -0
  65. package/lib/scalars/network/ip-v4.tsp +17 -0
  66. package/lib/scalars/network/ip-v6.tsp +18 -0
  67. package/lib/scalars/network/ip.tsp +17 -0
  68. package/lib/scalars/network/mac.tsp +16 -0
  69. package/lib/scalars/network/phone-number.tsp +18 -0
  70. package/lib/scalars/network.tsp +11 -0
  71. package/lib/scalars/number/big-int.tsp +15 -0
  72. package/lib/scalars/number/long.tsp +17 -0
  73. package/lib/scalars/number/negative-float.tsp +13 -0
  74. package/lib/scalars/number/negative-int.tsp +15 -0
  75. package/lib/scalars/number/non-negative-float.tsp +13 -0
  76. package/lib/scalars/number/non-negative-int.tsp +15 -0
  77. package/lib/scalars/number/non-positive-float.tsp +13 -0
  78. package/lib/scalars/number/non-positive-int.tsp +15 -0
  79. package/lib/scalars/number/port.tsp +14 -0
  80. package/lib/scalars/number/positive-float.tsp +13 -0
  81. package/lib/scalars/number/positive-int.tsp +15 -0
  82. package/lib/scalars/number/safe-int.tsp +16 -0
  83. package/lib/scalars/number.tsp +13 -0
  84. package/lib/scalars/string/emoji.tsp +21 -0
  85. package/lib/scalars/string/non-empty-string.tsp +15 -0
  86. package/lib/scalars/string.tsp +3 -0
  87. package/lib/scalars/value.tsp +2 -0
  88. package/lib/scalars.tsp +13 -12
  89. package/package.json +3 -3
  90. package/templates/nxgt/main.tsp +11 -4
@@ -0,0 +1,288 @@
1
+ # Scalars
2
+
3
+ The scalars of [`@nxgt/graphql-scalars`](https://www.npmjs.com/package/@nxgt/graphql-scalars)
4
+ as TypeSpec scalars, so a spec types a latitude, a currency or an IPv4 address
5
+ once and every generator and validator downstream agrees on it.
6
+
7
+ ```tsp
8
+ import "@typespec/http";
9
+ import "@nxgt/typespec";
10
+
11
+ using Http;
12
+ using Nxgt;
13
+
14
+ @service(#{ title: "Places" })
15
+ namespace Places;
16
+
17
+ model Place {
18
+ name: string;
19
+ location: {
20
+ latitude: latitude;
21
+ longitude: longitude;
22
+ };
23
+ currency: currency;
24
+ openedOn: date;
25
+ homepage?: httpUrl;
26
+ contact?: emailAddress;
27
+ seats: positiveInt;
28
+ }
29
+
30
+ @route("/places") @post op createPlace(@body place: Place): Place;
31
+ ```
32
+
33
+ Each property becomes a `$ref` to a component named as the GraphQL scalar:
34
+
35
+ ```yaml
36
+ components:
37
+ schemas:
38
+ Currency:
39
+ type: string
40
+ pattern: ^[A-Z]{3}$
41
+ format: currency
42
+ description: |-
43
+ An ISO 4217 currency code, uppercase: `EUR`, `USD`. ...
44
+ x-nxgt-scalar: Currency
45
+ Latitude:
46
+ type: number
47
+ format: double
48
+ minimum: -90
49
+ maximum: 90
50
+ x-nxgt-scalar: Latitude
51
+ PositiveInt:
52
+ type: integer
53
+ format: int32
54
+ minimum: 1
55
+ maximum: 2147483647
56
+ x-nxgt-scalar: PositiveInt
57
+ ```
58
+
59
+ ## What a scalar emits
60
+
61
+ | Emitted | Value |
62
+ | --- | --- |
63
+ | The component | The GraphQL name (`@friendlyName`): `Latitude`, `CIDRv4`, `EmailAddress`. It is global: a model of that name in your spec collides with it. |
64
+ | `format` | JSON Schema's when one exists (`date`, `date-time`, `duration`, `email`, `hostname`, `ipv4`, `ipv6`, `uri`, `uuid`), otherwise the kebab-case name (`iban`, `local-time`, `cidr-v4`); `base64` has OpenAPI's `byte`. The numbers keep the format TypeSpec writes from the base type. |
65
+ | `pattern`, `minimum`, `maximum` | The closest the GraphQL rule goes in a pattern or in bounds. |
66
+ | `x-nxgt-scalar` | The GraphQL name, so a generator can tell the rule from the shape. |
67
+ | `description` | The rule in words, and what the pattern leaves out. |
68
+
69
+ A generator that knows nothing of `x-nxgt-scalar` still validates the pattern
70
+ and the bounds. A later `@nxgt/openapi-codegen` release will map it to the exact
71
+ Zod schema from `@nxgt/zod` ([roadmap](../roadmap.md)); until then, what a
72
+ scalar leaves out is not checked.
73
+
74
+ ## Names
75
+
76
+ The identifiers are camelCase: `ipV4`, `cidrV6`, `uuidV7`, `hexColorCode`. The
77
+ component keeps the GraphQL spelling.
78
+
79
+ - `url` and `duration` are TypeSpec built-ins, and a scalar of that name makes
80
+ `url` ambiguous under `using Nxgt`. They are `httpUrl` and `isoDuration`.
81
+ - `JSON`, `JSONObject` and `Void` are not declared: write TypeSpec's own
82
+ `unknown`, `Record<unknown>` and `void`.
83
+ - A declaration of yours named like a scalar shadows it. In a spec with an
84
+ operation `locale`, write `Nxgt.locale`.
85
+
86
+ ```tsp
87
+ @get op locale(): string; // yours
88
+ model Page { language: Nxgt.locale; } // the scalar
89
+ ```
90
+
91
+ ## Identifiers
92
+
93
+ `uuid`, with the id columns, is also in [Scalars and columns](columns.md).
94
+
95
+ | Scalar | Component | Format | Takes | Left out of the pattern |
96
+ | --- | --- | --- | --- | --- |
97
+ | `uuid` | `UUID` | `uuid` | `550e8400-e29b-41d4-a716-446655440000`, the nil and the max UUID | |
98
+ | `uuidV4` | `UUIDv4` | `uuid` | `123e4567-e89b-42d3-a456-426614174000` | |
99
+ | `uuidV7` | `UUIDv7` | `uuid` | `017f22e2-79b0-7cc3-98c4-dc0c0c07398f` | |
100
+ | `guid` | `GUID` | `uuid` | any 8-4-4-4-12 hexadecimal, `123e4567-e89b-12d3-c456-426614174000` | no version, no variant |
101
+ | `ulid` | `ULID` | `ulid` | `01ARZ3NDEKTSV4RRFFQ69G5FAV`: 26 Crockford base32 characters, the first 0 to 7, any case | |
102
+ | `cuid2` | `Cuid2` | `cuid2` | `tz4a98xxat96iws9zmbrgj3a`, `ab`: a lowercase letter, then lowercase letters and digits, 2 to 32 characters | |
103
+ | `nanoId` | `NanoID` | `nano-id` | `V1StGXR8_Z5jdHi6B-myT`: 21 characters of `A-Za-z0-9_-`; a custom length is refused | |
104
+ | `ksuid` | `KSUID` | `ksuid` | `0ujtsYcgvSTl8PAuAdqWYSMnLOv`: 27 base62 characters, up to `aWgEPTl1tmebfsQzFP4bxwgy80V` | |
105
+ | `xid` | `XID` | `xid` | `9m4e2mr0ui3e8a215n4g`: 20 lowercase base32hex characters, the last `0` or `g` | |
106
+ | `objectId` | `ObjectID` | `object-id` | `507f1f77bcf86cd799439011`: 24 hexadecimal digits, any case | |
107
+ | `isbn` | `ISBN` | `isbn` | `0306406152`, `080442957X`, `9780306406157`: digits only, no hyphen | the check digit (`9780306406158` passes) |
108
+ | `semVer` | `SemVer` | `sem-ver` | `1.2.3`, `1.0.0-rc.1+build.5`: no `v` prefix, no leading zero | |
109
+
110
+ `uuid` refuses a UUID of no known version (`...-91d4-...`); `guid` is the shape
111
+ only. Case is kept as sent.
112
+
113
+ ## Encodings
114
+
115
+ | Scalar | Component | Format | Takes | Left out of the pattern |
116
+ | --- | --- | --- | --- | --- |
117
+ | `base64` | `Base64` | `byte` | `aGk=`, `a+b/`, `""`: RFC 4648 with padding, in its one canonical spelling (`YR==` is refused) | |
118
+ | `base64Url` | `Base64URL` | `base64-url` | `aGk`, `a-b_`: `-` and `_`, no padding, canonical | |
119
+ | `hexadecimal` | `Hexadecimal` | `hexadecimal` | `deadBEEF`, `abc`: one or more digits, any case, no `0x` | |
120
+ | `jwt` | `JWT` | `jwt` | three base64url parts joined by `.`, `eyJhbGci….eyJzdWIi….dozjgNry…` | that each part is canonical and the first two are JSON objects, `alg: none`, the signature |
121
+ | `sha256` | `SHA256` | `sha256` | 64 hexadecimal digits, any case | |
122
+ | `sha512` | `SHA512` | `sha512` | 128 hexadecimal digits, any case | |
123
+
124
+ `base64` carries the OpenAPI format `byte`, which the generator turns into
125
+ `z.base64()` in front of the pattern (`zBase64 = z.base64().regex(…)`); the
126
+ others are `z.string().regex(…)`. A `jwt` is a shape, not a verification: check
127
+ the token's signature in your code.
128
+
129
+ ## Strings
130
+
131
+ | Scalar | Component | Format | Takes | Left out of the pattern |
132
+ | --- | --- | --- | --- | --- |
133
+ | `nonEmptyString` | `NonEmptyString` | `non-empty-string` | `a`, ` a `: at least one non-white-space character (ECMAScript `\s`); kept as sent, not trimmed; `""`, `" "` and a lone no-break space are refused | |
134
+ | `emoji` | `Emoji` | `emoji` | `😀`, `👍🏽`, `👨‍👩‍👧`, `🇫🇷`, `1️⃣`: exactly one emoji | tag sequences (the flags of England, Scotland, Wales), segmentation into user-perceived characters, so two emoji in a row may pass |
135
+
136
+ The `emoji` pattern uses Unicode property escapes and needs the `u` flag;
137
+ the generated Zod regex has it.
138
+
139
+ ## Numbers
140
+
141
+ Numbers are JSON numbers, except `BigInt` and `Long`, which are decimal strings
142
+ because a JSON number loses precision past 2^53.
143
+
144
+ | Scalar | Component | Format | Takes | Left out of the pattern |
145
+ | --- | --- | --- | --- | --- |
146
+ | `positiveInt` | `PositiveInt` | `int32` | `1` to `2147483647` | `-0` |
147
+ | `nonNegativeInt` | `NonNegativeInt` | `int32` | `0` to `2147483647` | `-0` |
148
+ | `negativeInt` | `NegativeInt` | `int32` | `-2147483648` to `-1` | `-0` |
149
+ | `nonPositiveInt` | `NonPositiveInt` | `int32` | `-2147483648` to `0` | `-0` |
150
+ | `safeInt` | `SafeInt` | `int64` | the integers JavaScript holds exactly, `-9007199254740991` to `9007199254740991` | `-0` |
151
+ | `port` | `Port` | `int32` | `0` to `65535` | `-0` |
152
+ | `positiveFloat` | `PositiveFloat` | `double` | `0.5`, above 0 | |
153
+ | `nonNegativeFloat` | `NonNegativeFloat` | `double` | `0`, `1.5` | |
154
+ | `negativeFloat` | `NegativeFloat` | `double` | `-0.5`, below 0 | |
155
+ | `nonPositiveFloat` | `NonPositiveFloat` | `double` | `0`, `-1.5` | |
156
+ | `bigInt` | `BigInt` | `big-int` | `"123456789012345678901234567890"`: no leading zero, no `+`, no `-0` | |
157
+ | `long` | `Long` | `int64` | `"9223372036854775807"`, the signed 64-bit range | |
158
+
159
+ `long` is a string with `format: int64`, the OpenAPI idiom for an int64 sent as
160
+ a string; `timestamp` and `safeInt` are JSON numbers with the same format.
161
+
162
+ An integer scalar accepts `-0`, which GraphQL refuses and no JSON Schema can
163
+ express.
164
+
165
+ ## Date and time
166
+
167
+ | Scalar | Component | Format | Takes | Left out of the pattern |
168
+ | --- | --- | --- | --- | --- |
169
+ | `date` | `Date` | `date` | `2024-02-29` (an impossible day is refused) | |
170
+ | `dateTime` | `DateTime` | `date-time` | `2024-03-10T12:00:00+02:00`: an offset is required, `-00:00` refused | the range 0000 to 9999 in UTC, the leap second |
171
+ | `time` | `Time` | `time` | `10:15:30Z`, `10:15:30.5+02:00` | |
172
+ | `localDateTime` | `LocalDateTime` | `local-date-time` | `2024-03-10T10:15`, `2024-03-10T10:15:30`: no offset | |
173
+ | `localTime` | `LocalTime` | `local-time` | `10:15`, `10:15:30.5`: no offset | |
174
+ | `isoDuration` | `Duration` | `duration` | `P1Y2M3DT4H5M6S`, `PT0.5S`, `P2W`: weeks alone, no sign | |
175
+ | `utcOffset` | `UtcOffset` | `utc-offset` | `+05:30`, from `-12:00` to `+14:00`; `-00:00` refused | |
176
+ | `timeZone` | `TimeZone` | `time-zone` | `Europe/Paris`, `UTC` | the list of IANA names |
177
+ | `timestamp` | `Timestamp` | `int64` | milliseconds since the epoch, `1710065730000`, within ±8640000000000000 | |
178
+
179
+ A well-shaped unknown time zone name (`Mars/Olympus`) passes
180
+ ([Known gaps](#known-gaps)).
181
+
182
+ ## Geo
183
+
184
+ | Scalar | Component | Format | Takes | Left out of the pattern |
185
+ | --- | --- | --- | --- | --- |
186
+ | `latitude` | `Latitude` | `double` | `48.8566`, from -90 to 90 | |
187
+ | `longitude` | `Longitude` | `double` | `2.3522`, from -180 to 180 | |
188
+
189
+ Both are numbers, never strings.
190
+
191
+ ## Colors
192
+
193
+ CSS color syntax, in the comma form, one space after each comma.
194
+
195
+ | Scalar | Component | Format | Takes | Left out of the pattern |
196
+ | --- | --- | --- | --- | --- |
197
+ | `hexColorCode` | `HexColorCode` | `hex-color-code` | `#f00`, `#f008`, `#ff0000`, `#ff000080`, any case | |
198
+ | `rgb` | `RGB` | `rgb` | `rgb(255, 0, 0)`; no percentage, no leading zero | |
199
+ | `rgba` | `RGBA` | `rgba` | `rgba(255, 0, 0, 0.5)`: the alpha is `0`, `1` or a fraction with a leading `0` and no trailing zero | |
200
+ | `hsl` | `HSL` | `hsl` | `hsl(120, 100%, 50%)`: hue 0 to 359, no unit | |
201
+ | `hsla` | `HSLA` | `hsla` | `hsla(120, 100%, 50%, 0.5)` | |
202
+
203
+ `rgb(255,0,0)`, `rgb(255 0 0)` and `RGB(255, 0, 0)` are refused.
204
+
205
+ ## Network
206
+
207
+ | Scalar | Component | Format | Takes | Left out of the pattern |
208
+ | --- | --- | --- | --- | --- |
209
+ | `emailAddress` (alias `email`) | `EmailAddress` | `email` | `ada@example.com`, `a.b+c@sub.example.org`: no quoted local part, at least two labels | whether the domain exists |
210
+ | `hostname` | `Hostname` | `hostname` | `api.example.com`, `localhost`, `xn--bcher-kva.example`; not `1.2.3.4`, not Unicode | nothing is resolved |
211
+ | `httpUrl` | `URL` | `uri` | `https://example.com/a?b=c`, `http://localhost:3000`, `https://[2001:db8::1]/`; no other scheme, no user info | |
212
+ | `ipV4` | `IPv4` | `ipv4` | `192.168.0.1`: no leading zero | |
213
+ | `ipV6` | `IPv6` | `ipv6` | `2001:db8::1`, `::1`, `::ffff:192.0.2.1`, any case; no zone | |
214
+ | `ip` | `IP` | `ip` | either of the two | |
215
+ | `cidrV4` | `CIDRv4` | `cidr-v4` | `10.0.0.0/8`, `0.0.0.0/0` | that the address starts its block |
216
+ | `cidrV6` | `CIDRv6` | `cidr-v6` | `2001:db8::/32`, `::/0` | that the address starts its block |
217
+ | `mac` | `MAC` | `mac` | `00:1a:2b:3c:4d:5e`; not the hyphen or dotted form | |
218
+ | `phoneNumber` | `PhoneNumber` | `phone-number` | `+33612345678`: E.164, 7 to 15 digits, no separator | whether the number exists |
219
+
220
+ `httpUrl` is for a page's address; it is generated as
221
+ `zURL = z.string().regex(…)`: the pattern is the rule, and keeps to `http:`
222
+ and `https:` (`javascript:alert(1)` is refused). The generator leaves out
223
+ `z.url()` for it, which trims its input. TypeSpec's own `url` takes any
224
+ scheme and is generated as a bare `z.url()`.
225
+
226
+ ## Locale
227
+
228
+ | Scalar | Component | Format | Takes | Left out of the pattern |
229
+ | --- | --- | --- | --- | --- |
230
+ | `countryCode` | `CountryCode` | `country-code` | `FR`, `US`: ISO 3166-1 alpha-2, uppercase | the list of assigned codes (`ZZ` passes) |
231
+ | `locale` | `Locale` | `locale` | `fr`, `fr-FR`, `zh-Hant-TW`, `en-US-u-ca-buddhist`: BCP 47 in canonical case, up to 255 characters | the IANA registry, the order of extensions |
232
+
233
+ ## Finance
234
+
235
+ | Scalar | Component | Format | Takes | Left out of the pattern |
236
+ | --- | --- | --- | --- | --- |
237
+ | `currency` | `Currency` | `currency` | `EUR`, `USD`: ISO 4217, uppercase | the list of codes (`ZZZ` passes) |
238
+ | `iban` | `IBAN` | `iban` | `FR1420041010050500013M02606`: uppercase, no spaces | the mod 97 checksum, the country and its length |
239
+
240
+ ## Values
241
+
242
+ `JSON`, `JSONObject` and `Void` are not scalars here. Write `unknown`,
243
+ `Record<unknown>` and `void`.
244
+
245
+ ## In a route
246
+
247
+ The scalars work anywhere a type does: a path, a query, a header, a body.
248
+
249
+ ```tsp
250
+ import "@typespec/http";
251
+ import "@nxgt/typespec";
252
+
253
+ using Http;
254
+ using Nxgt;
255
+
256
+ @service(#{ title: "Inventory" })
257
+ namespace Inventory;
258
+
259
+ model Host {
260
+ @visibility(Lifecycle.Read) id: uuid;
261
+ name: hostname;
262
+ address: ip;
263
+ network: cidrV4;
264
+ port: port;
265
+ }
266
+
267
+ @route("/hosts")
268
+ interface Hosts {
269
+ @get list(@query ipPrefix?: cidrV4): Host[];
270
+ @get @route("{hostId}") read(@path hostId: uuid): Host;
271
+ }
272
+ ```
273
+
274
+ A request that breaks a pattern or a bound is answered `400` by the generated
275
+ validator, the same as any other schema.
276
+
277
+ ## Known gaps
278
+
279
+ The pattern is an approximation. Three things a consumer meets:
280
+
281
+ - An integer scalar accepts `-0`.
282
+ - A scalar that depends on a list (`countryCode`, `currency`, `timeZone`, the
283
+ `iban` checksum, the `locale` registry) accepts a well-shaped unknown value
284
+ until the generator maps `x-nxgt-scalar` to `@nxgt/zod`.
285
+ - A declaration named like a scalar shadows it: write `Nxgt.locale`.
286
+
287
+ See also [Scalars and columns](columns.md) for `uuid`, `email` and the columns,
288
+ and the [troubleshooting](../troubleshooting.md) page.
package/docs/roadmap.md CHANGED
@@ -8,6 +8,12 @@ generated by `@nxgt/openapi-codegen` in CI before it ships.
8
8
 
9
9
  Nothing in progress.
10
10
 
11
+ ## Next
12
+
13
+ - **Exact scalar rules in the generated code.** `@nxgt/openapi-codegen` maps
14
+ `x-nxgt-scalar` to the exact Zod schema from `@nxgt/zod`: the checksums,
15
+ the ISO and IANA lists and the `Intl` rules the patterns leave out.
16
+
11
17
  ## Later
12
18
 
13
19
  - **`QUERY` for `search` and `query`.** The HTTP `QUERY` method, a safe
@@ -20,6 +26,22 @@ Nothing in progress.
20
26
 
21
27
  ## Shipped
22
28
 
29
+ ### 0.11.0
30
+
31
+ - **Request body aliases.** `CreateRequest<T>`, `UpdateRequest<T>` and
32
+ `PatchRequest<T>` are TypeSpec's `Create`, `Update` and `MergePatchUpdate`
33
+ named `Create<T>Request`, `Update<T>Request` and `Patch<T>Request`, so the
34
+ generated types and schemas share one convention. `Create<T>` and
35
+ `MergePatchUpdate<T>` stay usable.
36
+
37
+ ### 0.10.0
38
+
39
+ - **Every scalar of `@nxgt/graphql-scalars`.** The scalars, under the same
40
+ names, as camelCase TypeSpec scalars: each emits a component named as in
41
+ GraphQL, a format, a pattern or bounds approximating the rule, and
42
+ `x-nxgt-scalar`. `uuid` and `email` take the GraphQL rules; the components
43
+ `Uuid` and `Email` are now `UUID` and `EmailAddress`.
44
+
23
45
  ### 0.9.0
24
46
 
25
47
  - **A `tsp init` template.** `tsp init` with the package's template
@@ -13,7 +13,7 @@ one name with `name_collision`.
13
13
  optional, and the generator adds its own 400 beside it.
14
14
 
15
15
  ```tsp
16
- @patch updatePost(@path postId: string, @body post: MergePatchUpdate<Post>): Post | BadRequest;
16
+ @patch patchPost(@path postId: string, @body post: PatchRequest<Post>): Post | BadRequest;
17
17
  ```
18
18
 
19
19
  ## `Couldn't resolve import "@nxgt/typespec"`
@@ -92,8 +92,9 @@ In GitHub Actions, add `oven-sh/setup-bun@v2` before the step.
92
92
 
93
93
  **When:** `tsp compile` runs on a spec that declares its own model named like
94
94
  one of the library's: `BadRequestBody` … `InternalServerErrorBody`,
95
- `ValidationIssue`, `ValidationTarget`, `Uuid` or `Email` when the spec uses
96
- `uuid` or `email`, or the page of one of its models,
95
+ `ValidationIssue`, `ValidationTarget`, `UUID` or `EmailAddress` when the spec
96
+ uses `uuid` or `email` (or any other scalar, under its GraphQL name: `Latitude`,
97
+ `Currency`), or the page of one of its models,
97
98
  such as `PostPage` beside `Page<Post>`, or `CommentCursorPage` beside
98
99
  `CursorPage<Comment>`.
99
100
 
@@ -489,6 +490,97 @@ linter:
489
490
  '@nxgt/typespec/error-body-shape': 'the legacy routes answer their own errors'
490
491
  ```
491
492
 
493
+ ## `@typespec/openapi3/invalid-model-property: 'Operation' cannot be specified as a model property.`
494
+
495
+ **When:** `tsp compile` runs on a spec that has `using Nxgt` and declares, in
496
+ its own namespace, something named like a scalar (`locale`, `date`, `time`,
497
+ `port`, `hostname`…) and also uses the scalar of that name.
498
+
499
+ ```tsp
500
+ using Nxgt;
501
+
502
+ @get op locale(): string;
503
+ model Page { language: locale; }
504
+ ```
505
+
506
+ ```text
507
+ error @typespec/openapi3/invalid-model-property: 'Operation' cannot be specified as a model property.
508
+ ```
509
+
510
+ **Why:** a declaration of the spec's own namespace shadows what `using Nxgt`
511
+ brings in, without a word. With an operation the name resolves to the
512
+ operation, and the emitter refuses it. With a model, `model date { … }` and
513
+ `day: date`, the property silently gets the model, a `$ref` to `date` where
514
+ `Date` was meant.
515
+
516
+ **Fix:** write the scalar with its namespace, or rename your declaration:
517
+
518
+ ```tsp
519
+ model Page { language: Nxgt.locale; }
520
+ ```
521
+
522
+ ## `` A value the GraphQL scalar refuses is accepted: `ZZ`, `ZZZ`, `Mars/Olympus`, a bad IBAN checksum ``
523
+
524
+ **When:** a request carries a value that `@nxgt/graphql-scalars` refuses but
525
+ the generated validator accepts and answers `2xx`: a country code that is not
526
+ assigned (`ZZ`), a currency (`ZZZ`), an IBAN with a wrong checksum, an ISBN
527
+ with a wrong check digit, a time zone (`Mars/Olympus`) or a locale that is not
528
+ in the registry, a JWT whose segments are not a token, an `Emoji` that is not
529
+ one.
530
+
531
+ **Why:** the scalar's pattern checks the shape. A list (`countryCode`,
532
+ `currency`, `timeZone`, `locale`) or a checksum (`iban`, ISBN) does not fit in
533
+ a pattern. `x-nxgt-scalar` names the exact rule, but `@nxgt/openapi-codegen`
534
+ does not map it to the `@nxgt/zod` schema yet ([roadmap](roadmap.md)).
535
+
536
+ **Fix:** until it does, check the value in the handler against the exact
537
+ rule, the list or the checksum, from `@nxgt/zod` or your own table, and answer
538
+ `400` as the generated validator does:
539
+
540
+ ```ts
541
+ if (!knownCountries.has(body.country)) {
542
+ return c.json({ status: 400, message: 'Unknown country code' }, 400);
543
+ }
544
+ ```
545
+
546
+ ## `An integer scalar accepts -0`
547
+
548
+ **When:** a body carries `-0` for `positiveInt`, `nonNegativeInt`, `port`,
549
+ `safeInt` and the other integer scalars, and the validator lets it through
550
+ where `@nxgt/graphql-scalars` refuses it.
551
+
552
+ **Why:** `-0` is a JSON number equal to `0`, which no JSON Schema keyword can
553
+ tell from `0`. For `nonNegativeInt`, `nonPositiveInt`, `port`, the bound holds,
554
+ so the value passes (`positiveInt` and `negativeInt` refuse it by their bound).
555
+
556
+ **Fix:** normalise in the handler when the sign matters:
557
+
558
+ ```ts
559
+ const quantity = Object.is(body.quantity, -0) ? 0 : body.quantity;
560
+ ```
561
+
562
+ ## `UUID`, `EmailAddress`: generated `Uuid` and `Email` are gone, and an id is answered 400
563
+
564
+ **When:** after upgrading to 0.10.0, the generated code no longer exports
565
+ `Uuid`, `zUuid`, `Email` or `zEmail` (`Module has no exported member 'Uuid'`),
566
+ or a request whose id was accepted before is answered `400`.
567
+
568
+ **Why:** the schemas follow `@nxgt/graphql-scalars`' names, `UUID` and
569
+ `EmailAddress`, and `uuid` takes its rule: an RFC 9562 version 1 to 8 with the
570
+ RFC variant, or the nil or max UUID. An id of no known version
571
+ (`…-91d4-…`) is refused.
572
+
573
+ **Fix:** rename the imports. For an id that is a shape only, write `guid`
574
+ instead of `uuid`:
575
+
576
+ ```ts
577
+ import { type UUID, zUUID, type EmailAddress, zEmailAddress } from './generated';
578
+ ```
579
+
580
+ ```tsp
581
+ model Legacy { id: guid; }
582
+ ```
583
+
492
584
  ## `Unknown decorator @operationId`
493
585
 
494
586
  **When:** a spec writes `@operationId` next to `@operationIds`.
package/lib/main.tsp CHANGED
@@ -9,3 +9,4 @@ import "./scalars.tsp";
9
9
  import "./columns.tsp";
10
10
  import "./headers.tsp";
11
11
  import "./sorting.tsp";
12
+ import "./requests.tsp";
@@ -0,0 +1,36 @@
1
+ import "@typespec/http";
2
+
3
+ using Http;
4
+
5
+ namespace Nxgt;
6
+
7
+ /**
8
+ * The body of a create: the properties of `T` visible on create, named
9
+ * `Create<T>Request` (`CreatePostRequest`). A nested model filtered the same
10
+ * way is named alike (`CreateAddressRequest`); one left whole keeps its name.
11
+ */
12
+ alias CreateRequest<T extends Reflection.Model> = Create<
13
+ T,
14
+ "Create{name}Request"
15
+ >;
16
+
17
+ /**
18
+ * The body of a full update, as a `PUT` replaces the resource: the properties
19
+ * of `T` visible on update, each as optional as `T` declares it, named
20
+ * `Update<T>Request` (`UpdatePostRequest`).
21
+ */
22
+ alias UpdateRequest<T extends Reflection.Model> = Update<
23
+ T,
24
+ "Update{name}Request"
25
+ >;
26
+
27
+ /**
28
+ * The body of a partial update, as a JSON Merge Patch
29
+ * (`application/merge-patch+json`): the properties of `T` visible on update,
30
+ * all optional, a nullable one sent as `null` to clear it, named
31
+ * `Patch<T>Request` (`PatchPostRequest`).
32
+ */
33
+ alias PatchRequest<T extends Reflection.Model> = MergePatchUpdate<
34
+ T,
35
+ "Patch{name}Request"
36
+ >;
@@ -0,0 +1,17 @@
1
+ import "@typespec/openapi";
2
+
3
+ using TypeSpec.OpenAPI;
4
+
5
+ namespace Nxgt;
6
+
7
+ /**
8
+ * A CSS hex color: `#` then 3, 4, 6 or 8 hexadecimal digits, in any case, kept
9
+ * as sent: `#ff0000`. The 4 and 8 digit forms carry an alpha.
10
+ *
11
+ * Specified by https://www.w3.org/TR/css-color-4/#hex-notation.
12
+ */
13
+ @friendlyName("HexColorCode")
14
+ @format("hex-color-code")
15
+ @pattern("^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$")
16
+ @extension("x-nxgt-scalar", "HexColorCode")
17
+ scalar hexColorCode extends string;
@@ -0,0 +1,18 @@
1
+ import "@typespec/openapi";
2
+
3
+ using TypeSpec.OpenAPI;
4
+
5
+ namespace Nxgt;
6
+
7
+ /**
8
+ * A CSS `hsl()` color in the comma syntax: `hsl(120, 100%, 50%)`. A hue from 0
9
+ * to 359 with no unit (360 is written 0), then saturation and lightness as
10
+ * integer percentages from 0% to 100%.
11
+ *
12
+ * Specified by https://www.w3.org/TR/css-color-4/#the-hsl-notation.
13
+ */
14
+ @friendlyName("HSL")
15
+ @format("hsl")
16
+ @pattern("^hsl\\((?:3[0-5]\\d|[12]\\d\\d|[1-9]?\\d), (?:100|[1-9]?\\d)%, (?:100|[1-9]?\\d)%\\)$")
17
+ @extension("x-nxgt-scalar", "HSL")
18
+ scalar hsl extends string;
@@ -0,0 +1,18 @@
1
+ import "@typespec/openapi";
2
+
3
+ using TypeSpec.OpenAPI;
4
+
5
+ namespace Nxgt;
6
+
7
+ /**
8
+ * A CSS `hsla()` color in the comma syntax: `hsla(120, 100%, 50%, 0.5)`. As
9
+ * `hsl` plus an alpha: `0`, `1`, or a fraction with no trailing zero and a
10
+ * leading `0`.
11
+ *
12
+ * Specified by https://www.w3.org/TR/css-color-4/#the-hsl-notation.
13
+ */
14
+ @friendlyName("HSLA")
15
+ @format("hsla")
16
+ @pattern("^hsla\\((?:3[0-5]\\d|[12]\\d\\d|[1-9]?\\d), (?:100|[1-9]?\\d)%, (?:100|[1-9]?\\d)%, (?:0|1|0\\.\\d*[1-9])\\)$")
17
+ @extension("x-nxgt-scalar", "HSLA")
18
+ scalar hsla extends string;
@@ -0,0 +1,18 @@
1
+ import "@typespec/openapi";
2
+
3
+ using TypeSpec.OpenAPI;
4
+
5
+ namespace Nxgt;
6
+
7
+ /**
8
+ * A CSS `rgb()` color in the comma syntax: `rgb(255, 0, 0)`. Three integers
9
+ * from 0 to 255, no leading zero, no percentage, a `, ` between them, the
10
+ * function name in lower case.
11
+ *
12
+ * Specified by https://www.w3.org/TR/css-color-4/#rgb-functions.
13
+ */
14
+ @friendlyName("RGB")
15
+ @format("rgb")
16
+ @pattern("^rgb\\((?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d), (?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d), (?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)\\)$")
17
+ @extension("x-nxgt-scalar", "RGB")
18
+ scalar rgb extends string;
@@ -0,0 +1,18 @@
1
+ import "@typespec/openapi";
2
+
3
+ using TypeSpec.OpenAPI;
4
+
5
+ namespace Nxgt;
6
+
7
+ /**
8
+ * A CSS `rgba()` color in the comma syntax: `rgba(255, 0, 0, 0.5)`. As `rgb`
9
+ * plus an alpha: `0`, `1`, or a fraction with no trailing zero and a leading
10
+ * `0`.
11
+ *
12
+ * Specified by https://www.w3.org/TR/css-color-4/#rgb-functions.
13
+ */
14
+ @friendlyName("RGBA")
15
+ @format("rgba")
16
+ @pattern("^rgba\\((?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d), (?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d), (?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d), (?:0|1|0\\.\\d*[1-9])\\)$")
17
+ @extension("x-nxgt-scalar", "RGBA")
18
+ scalar rgba extends string;
@@ -0,0 +1,6 @@
1
+ // The color scalars, one file each under ./color/.
2
+ import "./color/hex-color-code.tsp";
3
+ import "./color/hsl.tsp";
4
+ import "./color/hsla.tsp";
5
+ import "./color/rgb.tsp";
6
+ import "./color/rgba.tsp";
@@ -0,0 +1,19 @@
1
+ import "@typespec/openapi";
2
+
3
+ using TypeSpec.OpenAPI;
4
+
5
+ namespace Nxgt;
6
+
7
+ /**
8
+ * An RFC 3339 date-time with its offset, `Z` or `±hh:mm`, kept as sent: `2024-03-10T12:00:00+02:00`.
9
+ * A time with no offset names no instant and is refused, and so is the offset `-00:00`:
10
+ * write `Z` or `+00:00`. An impossible day is refused. The range 0000 to 9999 in UTC is
11
+ * not in the pattern, nor is the leap second.
12
+ *
13
+ * Specified by https://www.rfc-editor.org/rfc/rfc3339.
14
+ */
15
+ @friendlyName("DateTime")
16
+ @format("date-time")
17
+ @pattern("^(?:\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|02-(?:0[1-9]|1\\d|2[0-8]))|(?:\\d\\d(?:0[48]|[2468][048]|[13579][26])|(?:[02468][048]|[13579][26])00)-02-29)T(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)(?:Z|\\+(?:[01]\\d|2[0-3]):[0-5]\\d|-(?!00:00)(?:[01]\\d|2[0-3]):[0-5]\\d)$")
18
+ @extension("x-nxgt-scalar", "DateTime")
19
+ scalar dateTime extends string;
@@ -0,0 +1,17 @@
1
+ import "@typespec/openapi";
2
+
3
+ using TypeSpec.OpenAPI;
4
+
5
+ namespace Nxgt;
6
+
7
+ /**
8
+ * A calendar date, `YYYY-MM-DD`, with no time and no time zone: `2024-02-29`. An impossible
9
+ * day, such as `2023-02-29`, is refused.
10
+ *
11
+ * Specified by https://www.rfc-editor.org/rfc/rfc3339#section-5.6.
12
+ */
13
+ @friendlyName("Date")
14
+ @format("date")
15
+ @pattern("^(?:\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|02-(?:0[1-9]|1\\d|2[0-8]))|(?:\\d\\d(?:0[48]|[2468][048]|[13579][26])|(?:[02468][048]|[13579][26])00)-02-29)$")
16
+ @extension("x-nxgt-scalar", "Date")
17
+ scalar date extends string;
@@ -0,0 +1,17 @@
1
+ import "@typespec/openapi";
2
+
3
+ using TypeSpec.OpenAPI;
4
+
5
+ namespace Nxgt;
6
+
7
+ /**
8
+ * An ISO 8601 duration, kept as sent and not normalised: `P1DT2H`. Weeks are not mixed with
9
+ * other units, and there is no sign. Named `isoDuration` because `duration` is TypeSpec's own.
10
+ *
11
+ * Specified by https://www.iso.org/iso-8601-date-and-time-format.html.
12
+ */
13
+ @friendlyName("Duration")
14
+ @format("duration")
15
+ @pattern("^P(?:\\d+W|(?!.*W)(?=\\d|T\\d)(?:\\d+Y)?(?:\\d+M)?(?:\\d+D)?(?:T(?=\\d)(?:\\d+H)?(?:\\d+M)?(?:\\d+(?:[.,]\\d+)?S)?)?)$")
16
+ @extension("x-nxgt-scalar", "Duration")
17
+ scalar isoDuration extends string;
@@ -0,0 +1,15 @@
1
+ import "@typespec/openapi";
2
+
3
+ using TypeSpec.OpenAPI;
4
+
5
+ namespace Nxgt;
6
+
7
+ /**
8
+ * A date and a time on the wall clock, with no offset, not even `Z`: `2024-03-10T10:15:30`.
9
+ * The seconds are optional. An impossible day is refused.
10
+ */
11
+ @friendlyName("LocalDateTime")
12
+ @format("local-date-time")
13
+ @pattern("^(?:\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|02-(?:0[1-9]|1\\d|2[0-8]))|(?:\\d\\d(?:0[48]|[2468][048]|[13579][26])|(?:[02468][048]|[13579][26])00)-02-29)T(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?$")
14
+ @extension("x-nxgt-scalar", "LocalDateTime")
15
+ scalar localDateTime extends string;