@nxgt/typespec 0.9.2 → 0.10.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 (81) hide show
  1. package/README.md +39 -8
  2. package/docs/README.md +1 -0
  3. package/docs/guide/columns.md +25 -11
  4. package/docs/guide/scalars.md +288 -0
  5. package/docs/roadmap.md +14 -0
  6. package/docs/troubleshooting.md +94 -2
  7. package/lib/scalars/color/hex-color-code.tsp +17 -0
  8. package/lib/scalars/color/hsl.tsp +18 -0
  9. package/lib/scalars/color/hsla.tsp +18 -0
  10. package/lib/scalars/color/rgb.tsp +18 -0
  11. package/lib/scalars/color/rgba.tsp +18 -0
  12. package/lib/scalars/color.tsp +6 -0
  13. package/lib/scalars/date-time/date-time.tsp +19 -0
  14. package/lib/scalars/date-time/date.tsp +17 -0
  15. package/lib/scalars/date-time/iso-duration.tsp +17 -0
  16. package/lib/scalars/date-time/local-date-time.tsp +15 -0
  17. package/lib/scalars/date-time/local-time.tsp +15 -0
  18. package/lib/scalars/date-time/time-zone.tsp +18 -0
  19. package/lib/scalars/date-time/time.tsp +17 -0
  20. package/lib/scalars/date-time/timestamp.tsp +15 -0
  21. package/lib/scalars/date-time/utc-offset.tsp +15 -0
  22. package/lib/scalars/date-time.tsp +10 -0
  23. package/lib/scalars/encoding/base64-url.tsp +17 -0
  24. package/lib/scalars/encoding/base64.tsp +18 -0
  25. package/lib/scalars/encoding/hexadecimal.tsp +15 -0
  26. package/lib/scalars/encoding/jwt.tsp +19 -0
  27. package/lib/scalars/encoding/sha256.tsp +16 -0
  28. package/lib/scalars/encoding/sha512.tsp +16 -0
  29. package/lib/scalars/encoding.tsp +7 -0
  30. package/lib/scalars/finance/currency.tsp +17 -0
  31. package/lib/scalars/finance/iban.tsp +19 -0
  32. package/lib/scalars/finance.tsp +3 -0
  33. package/lib/scalars/geo/latitude.tsp +14 -0
  34. package/lib/scalars/geo/longitude.tsp +14 -0
  35. package/lib/scalars/geo.tsp +3 -0
  36. package/lib/scalars/identifier/cuid2.tsp +17 -0
  37. package/lib/scalars/identifier/guid.tsp +12 -0
  38. package/lib/scalars/identifier/isbn.tsp +18 -0
  39. package/lib/scalars/identifier/ksuid.tsp +18 -0
  40. package/lib/scalars/identifier/nano-id.tsp +16 -0
  41. package/lib/scalars/identifier/object-id.tsp +16 -0
  42. package/lib/scalars/identifier/sem-ver.tsp +17 -0
  43. package/lib/scalars/identifier/ulid.tsp +17 -0
  44. package/lib/scalars/identifier/uuid-v4.tsp +16 -0
  45. package/lib/scalars/identifier/uuid-v7.tsp +16 -0
  46. package/lib/scalars/identifier/uuid.tsp +18 -0
  47. package/lib/scalars/identifier/xid.tsp +17 -0
  48. package/lib/scalars/identifier.tsp +13 -0
  49. package/lib/scalars/locale/country-code.tsp +17 -0
  50. package/lib/scalars/locale/locale.tsp +20 -0
  51. package/lib/scalars/locale.tsp +3 -0
  52. package/lib/scalars/network/cidr-v4.tsp +19 -0
  53. package/lib/scalars/network/cidr-v6.tsp +18 -0
  54. package/lib/scalars/network/email-address.tsp +20 -0
  55. package/lib/scalars/network/hostname.tsp +21 -0
  56. package/lib/scalars/network/http-url.tsp +21 -0
  57. package/lib/scalars/network/ip-v4.tsp +17 -0
  58. package/lib/scalars/network/ip-v6.tsp +18 -0
  59. package/lib/scalars/network/ip.tsp +17 -0
  60. package/lib/scalars/network/mac.tsp +16 -0
  61. package/lib/scalars/network/phone-number.tsp +18 -0
  62. package/lib/scalars/network.tsp +11 -0
  63. package/lib/scalars/number/big-int.tsp +15 -0
  64. package/lib/scalars/number/long.tsp +17 -0
  65. package/lib/scalars/number/negative-float.tsp +13 -0
  66. package/lib/scalars/number/negative-int.tsp +15 -0
  67. package/lib/scalars/number/non-negative-float.tsp +13 -0
  68. package/lib/scalars/number/non-negative-int.tsp +15 -0
  69. package/lib/scalars/number/non-positive-float.tsp +13 -0
  70. package/lib/scalars/number/non-positive-int.tsp +15 -0
  71. package/lib/scalars/number/port.tsp +14 -0
  72. package/lib/scalars/number/positive-float.tsp +13 -0
  73. package/lib/scalars/number/positive-int.tsp +15 -0
  74. package/lib/scalars/number/safe-int.tsp +16 -0
  75. package/lib/scalars/number.tsp +13 -0
  76. package/lib/scalars/string/emoji.tsp +21 -0
  77. package/lib/scalars/string/non-empty-string.tsp +15 -0
  78. package/lib/scalars/string.tsp +3 -0
  79. package/lib/scalars/value.tsp +2 -0
  80. package/lib/scalars.tsp +13 -12
  81. package/package.json +3 -3
package/README.md CHANGED
@@ -291,15 +291,34 @@ model Post {
291
291
 
292
292
  | Name | Emitted as | Generated | drizzle |
293
293
  | --- | --- | --- | --- |
294
- | `uuid` | the schema `Uuid`, `format: uuid` | `Uuid`, `z.guid()` | `id()` |
295
- | `email` | the schema `Email`, `format: email` | `Email`, `z.email()` | |
294
+ | `uuid` | the schema `UUID`, `format: uuid`, a pattern for versions 1–8 | `UUID`, `z.guid().regex(…)` | `id()` |
295
+ | `email` | `emailAddress`, the schema `EmailAddress`, `format: email` | `EmailAddress`, `z.string().regex(…)` | |
296
296
  | `...Timestamps` | `createdAt`, `updatedAt`, read-only | left out of `Create<T>` and updates | `timestamps()` |
297
297
  | `...SoftDelete` | `deletedAt: utcDateTime \| null`, read-only | | `softDelete()` |
298
298
  | `...Versioned` | `version: integer`, read and update | kept in `MergePatchUpdate<T>` | `version()` |
299
299
  | `...Actors<Id = uuid>` | `createdBy`, `updatedBy`, `deletedBy`, `Id \| null`, read-only | | `actors()` |
300
300
 
301
- TypeSpec's own `url` is the address of a page, `format: uri`, generated as
302
- `z.url()`. More in [Scalars and columns](docs/guide/columns.md).
301
+ Every scalar of [`@nxgt/graphql-scalars`](https://www.npmjs.com/package/@nxgt/graphql-scalars)
302
+ is declared too, as a camelCase scalar (`latitude`, `currency`, `positiveInt`,
303
+ `httpUrl`, `ipV4`, `isoDuration`):
304
+
305
+ ```tsp
306
+ model Place {
307
+ location: { latitude: latitude; longitude: longitude };
308
+ currency: currency;
309
+ openedOn: date;
310
+ homepage?: httpUrl;
311
+ seats: positiveInt;
312
+ }
313
+ ```
314
+
315
+ Each is an OpenAPI component named as in GraphQL (`Latitude`, `URL`), with a
316
+ `format`, a pattern or bounds approximating the rule, and `x-nxgt-scalar`
317
+ naming it. The exact rules (checksums, ISO and IANA lists) are outside the
318
+ pattern. TypeSpec's own `url` takes any scheme; `httpUrl` takes `http:` and
319
+ `https:`. Every scalar by category, in
320
+ [Scalars](docs/guide/scalars.md); the ids, the addresses and the columns, in
321
+ [Scalars and columns](docs/guide/columns.md).
303
322
 
304
323
  ### Headers
305
324
 
@@ -503,8 +522,18 @@ In the ruleset `@nxgt/typespec/recommended`; each is a warning, and named
503
522
 
504
523
  | Name | What it is |
505
524
  | --- | --- |
506
- | `uuid` | a string, `format: uuid`; the schema `Uuid` |
507
- | `email` | a string, `format: email`; the schema `Email` |
525
+ | `uuid` | an RFC 9562 UUID, `format: uuid` and a pattern; the schema `UUID` |
526
+ | `email` | an alias of `emailAddress`, `format: email` and a pattern; the schema `EmailAddress` |
527
+ | `guid`, `uuidV4`, `uuidV7` | a UUID of any shape, of version 4, of version 7; `GUID`, `UUIDv4`, `UUIDv7` |
528
+ | `ulid`, `cuid2`, `nanoId`, `ksuid`, `xid`, `objectId`, `isbn`, `semVer` | identifiers by shape: `ULID`, `Cuid2`, `NanoID`, `KSUID`, `XID`, `ObjectID`, `ISBN`, `SemVer` |
529
+ | `base64`, `base64Url`, `hexadecimal`, `jwt`, `sha256`, `sha512` | encodings and digests; `base64` has the format `byte` and is generated as `z.base64()` |
530
+ | `nonEmptyString`, `emoji` | a string with a visible character; one emoji |
531
+ | `positiveInt`, `nonNegativeInt`, `negativeInt`, `nonPositiveInt`, `safeInt`, `port`, `positiveFloat`, `nonNegativeFloat`, `negativeFloat`, `nonPositiveFloat`, `bigInt`, `long` | the numbers: `int32` bounds, JavaScript's safe integers, TCP ports, floats by sign, and 64-bit (`long`, `format: int64`) or larger (`bigInt`) integers as strings |
532
+ | `date`, `dateTime`, `time`, `localDateTime`, `localTime`, `isoDuration`, `utcOffset`, `timeZone`, `timestamp` | dates, times and durations as RFC 3339 and ISO 8601 write them |
533
+ | `latitude`, `longitude` | decimal degrees, as numbers |
534
+ | `hexColorCode`, `rgb`, `rgba`, `hsl`, `hsla` | CSS colors |
535
+ | `emailAddress`, `hostname`, `httpUrl`, `ipV4`, `ipV6`, `ip`, `cidrV4`, `cidrV6`, `mac`, `phoneNumber` | addresses; `httpUrl` is named so because `url` is TypeSpec's |
536
+ | `countryCode`, `locale`, `currency`, `iban` | ISO and BCP 47 codes, by shape |
508
537
  | `Timestamps` | `createdAt`, `updatedAt: utcDateTime`, read-only |
509
538
  | `SoftDelete` | `deletedAt: utcDateTime \| null`, read-only |
510
539
  | `Versioned` | `version: integer`, read and update |
@@ -600,8 +629,8 @@ envelope and the rate limit's headers.
600
629
  `3.1.0/openapi.yaml` and `3.2.0/openapi.yaml`: point the generator's input
601
630
  at one of them.
602
631
  - **The library's schema names are global.** `BadRequestBody` …
603
- `InternalServerErrorBody`, `ValidationIssue`, `ValidationTarget`, `Uuid`,
604
- `Email`, and each `<Item>Page` and `<Item>CursorPage` are emitted under
632
+ `InternalServerErrorBody`, `ValidationIssue`, `ValidationTarget`, the scalars' components
633
+ (`UUID`, `EmailAddress`, `Latitude`, …), and each `<Item>Page` and `<Item>CursorPage` are emitted under
605
634
  those names, without a namespace, once the spec uses them. A model of the
606
635
  same name in your spec then fails with `duplicate-type-name`
607
636
  ([troubleshooting](docs/troubleshooting.md)).
@@ -616,6 +645,8 @@ envelope and the rate limit's headers.
616
645
  what a handler answers;
617
646
  - [Authentication](docs/guide/auth.md): `JanusAuth`, and the guards'
618
647
  replies without a body;
648
+ - [Scalars](docs/guide/scalars.md): every scalar of `@nxgt/graphql-scalars`
649
+ by category, with what each takes and what its pattern leaves out;
619
650
  - [Scalars and columns](docs/guide/columns.md): `uuid`, `email`, and the
620
651
  columns `@nxgt/drizzle` stamps;
621
652
  - [Headers](docs/guide/headers.md): `Idempotency-Key`, the rate limit and
package/docs/README.md CHANGED
@@ -8,6 +8,7 @@ The [package README](../README.md) is the short version.
8
8
  | [Error replies](guide/errors.md) | declaring what an operation answers when it fails |
9
9
  | [Pagination](guide/pagination.md) | listing a collection a page at a time |
10
10
  | [Authentication](guide/auth.md) | protecting an operation with a `@nxgt/janus` session |
11
+ | [Scalars](guide/scalars.md) | typing a latitude, a currency, an IP address or a port, and knowing what each pattern leaves out |
11
12
  | [Scalars and columns](guide/columns.md) | typing ids and addresses, and the columns a row carries |
12
13
  | [Headers](guide/headers.md) | making a write idempotent, or declaring a rate limit |
13
14
  | [Sorting](guide/sorting.md) | letting a client filter and sort a list |
@@ -1,8 +1,9 @@
1
1
  # Scalars and columns
2
2
 
3
- Two scalars for the strings every API has, and the columns
3
+ Scalars for the ids and addresses every API has, and the columns
4
4
  [`@nxgt/drizzle`](https://www.npmjs.com/package/@nxgt/drizzle) stamps a row
5
- with, as models to spread.
5
+ with, as models to spread. The other scalars, by category, are in
6
+ [Scalars](scalars.md).
6
7
 
7
8
  ## Scalars
8
9
 
@@ -18,7 +19,7 @@ namespace Blog;
18
19
  model Author {
19
20
  @visibility(Lifecycle.Read) id: uuid;
20
21
  email: email;
21
- site?: url;
22
+ site?: httpUrl;
22
23
  }
23
24
 
24
25
  @route("/authors/{authorId}")
@@ -27,12 +28,25 @@ model Author {
27
28
 
28
29
  | Scalar | Emitted as | Generated | Refused |
29
30
  | --- | --- | --- | --- |
30
- | `uuid` | `$ref` to the schema `Uuid`: `type: string`, `format: uuid` | `type Uuid = string`, `zUuid = z.guid()` | `42`, in a path, a query or a body |
31
- | `email` | `$ref` to the schema `Email`: `format: email` | `type Email = string`, `zEmail = z.email()` | `not an address` |
32
- | `url` | TypeSpec's own: `format: uri`, inline | `z.url()` | `not a url` |
33
-
34
- `z.guid()` checks the shape of a UUID, as JSON Schema's `uuid` does, not
35
- the version and variant bits `z.uuid()` also checks.
31
+ | `uuid` | `$ref` to the schema `UUID`: `type: string`, `format: uuid`, a pattern, `x-nxgt-scalar: UUID` | `type UUID = string`, `zUUID = z.guid().regex(…)` | `42`, a UUID with no hyphens or of version 9, in a path, a query or a body |
32
+ | `email` | an alias of `emailAddress`: `$ref` to the schema `EmailAddress`, `format: email`, a pattern, `x-nxgt-scalar: EmailAddress` | `type EmailAddress = string`, `zEmailAddress = z.string().regex(…)` | `not an address`, `ada@localhost` |
33
+ | `httpUrl` | `$ref` to the schema `URL`: `format: uri`, a pattern for `http:` and `https:`, `x-nxgt-scalar: URL` | `type URL = string`, `zURL = z.string().regex(…)` | `not a url`, `javascript:alert(1)` |
34
+
35
+ TypeSpec's own `url` takes any scheme, inline, as `z.url()`; `httpUrl` is
36
+ the `http:` and `https:` page address. `httpUrl` and `email` are generated as
37
+ `z.string().regex(…)`: the pattern is the rule, as the generator leaves out
38
+ `z.url()`, which trims its input, and `z.email()`, which refuses
39
+ `ada@x.xn--p1ai`, an internationalised top-level domain `email` accepts.
40
+
41
+ `uuid` takes an RFC 9562 UUID, any version 1 to 8 with the RFC variant, or
42
+ the nil or the max UUID, in any case, as `@nxgt/graphql-scalars`' `UUID`
43
+ does: the pattern adds those checks to the shape `z.guid()` checks. `uuidV4`,
44
+ `uuidV7` and `guid` (any 8-4-4-4-12 hexadecimal) are the narrower and the
45
+ looser ones. `email` is `emailAddress`, under the name it had first.
46
+
47
+ The schema names changed in 0.10.0: `Uuid` and `Email` became `UUID` and
48
+ `EmailAddress`, and a UUID of no known version is refused. A spec that needs
49
+ the old shape-only id writes `guid`.
36
50
 
37
51
  ## Columns
38
52
 
@@ -112,6 +126,6 @@ without it is not checked against the version.
112
126
 
113
127
  ## Names
114
128
 
115
- `Uuid` and `Email` are schemas of their own, and their names are global: a
116
- model named `Email` in a spec that also uses `email` collides with it
129
+ `UUID` and `EmailAddress` are schemas of their own, and their names are global: a
130
+ model named `EmailAddress` in a spec that also uses `email` collides with it
117
131
  ([troubleshooting](../troubleshooting.md)).
@@ -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,14 @@ Nothing in progress.
20
26
 
21
27
  ## Shipped
22
28
 
29
+ ### 0.10.0
30
+
31
+ - **Every scalar of `@nxgt/graphql-scalars`.** The scalars, under the same
32
+ names, as camelCase TypeSpec scalars: each emits a component named as in
33
+ GraphQL, a format, a pattern or bounds approximating the rule, and
34
+ `x-nxgt-scalar`. `uuid` and `email` take the GraphQL rules; the components
35
+ `Uuid` and `Email` are now `UUID` and `EmailAddress`.
36
+
23
37
  ### 0.9.0
24
38
 
25
39
  - **A `tsp init` template.** `tsp init` with the package's template
@@ -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`.
@@ -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;