Leon4gr45 commited on
Commit
04f1444
·
verified ·
1 Parent(s): 1477a90

Upload folder using huggingface_hub (part 3)

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. api/spec/packages/legacy/src/rest.tsp +43 -0
  2. api/spec/packages/legacy/src/subjects.tsp +156 -0
  3. api/spec/packages/legacy/src/types.tsp +447 -0
  4. api/spec/packages/legacy/tspconfig.client.yaml +21 -0
  5. api/spec/packages/legacy/tspconfig.yaml +14 -0
  6. api/spec/packages/typespec-go/.gitignore +2 -0
  7. api/spec/packages/typespec-go/README.md +76 -0
  8. api/spec/packages/typespec-go/package.json +34 -0
  9. api/spec/packages/typespec-go/src/components/GoClient.tsx +145 -0
  10. api/spec/packages/typespec-go/src/components/GoEnum.tsx +69 -0
  11. api/spec/packages/typespec-go/src/components/GoModels.tsx +206 -0
  12. api/spec/packages/typespec-go/src/components/GoResource.tsx +1000 -0
  13. api/spec/packages/typespec-go/src/components/GoStruct.tsx +41 -0
  14. api/spec/packages/typespec-go/src/components/GoTypeSpecEnum.tsx +55 -0
  15. api/spec/packages/typespec-go/src/components/GoUnion.tsx +389 -0
  16. api/spec/packages/typespec-go/src/emitter.tsx +602 -0
  17. api/spec/packages/typespec-go/src/go-types.tsx +731 -0
  18. api/spec/packages/typespec-go/src/grouping.ts +167 -0
  19. api/spec/packages/typespec-go/src/index.ts +2 -0
  20. api/spec/packages/typespec-go/src/lib.ts +102 -0
  21. api/spec/packages/typespec-go/src/operations.ts +470 -0
  22. api/spec/packages/typespec-go/src/projections.ts +1039 -0
  23. api/spec/packages/typespec-go/src/readme.ts +371 -0
  24. api/spec/packages/typespec-go/src/runtime-symbols.ts +81 -0
  25. api/spec/packages/typespec-go/src/runtime-templates.ts +868 -0
  26. api/spec/packages/typespec-go/src/stdlib.ts +89 -0
  27. api/spec/packages/typespec-go/test/assembly.test.ts +385 -0
  28. api/spec/packages/typespec-go/test/go-types.test.ts +100 -0
  29. api/spec/packages/typespec-go/test/operations.test.ts +586 -0
  30. api/spec/packages/typespec-go/test/projections.test.ts +504 -0
  31. api/spec/packages/typespec-go/test/resource-render.test.ts +324 -0
  32. api/spec/packages/typespec-go/tsconfig.json +34 -0
  33. api/spec/packages/typespec-typescript/.gitignore +2 -0
  34. api/spec/packages/typespec-typescript/package.json +38 -0
  35. api/spec/packages/typespec-typescript/src/ZodOperations.tsx +313 -0
  36. api/spec/packages/typespec-typescript/src/casing-gate.ts +250 -0
  37. api/spec/packages/typespec-typescript/src/casing.ts +18 -0
  38. api/spec/packages/typespec-typescript/src/components/ZodCustomTypeComponent.tsx +90 -0
  39. api/spec/packages/typespec-typescript/src/components/ZodOptions.tsx +24 -0
  40. api/spec/packages/typespec-typescript/src/components/ZodSchema.tsx +62 -0
  41. api/spec/packages/typespec-typescript/src/components/ZodSchemaDeclaration.tsx +64 -0
  42. api/spec/packages/typespec-typescript/src/components/index.ts +4 -0
  43. api/spec/packages/typespec-typescript/src/context/zod-options.ts +163 -0
  44. api/spec/packages/typespec-typescript/src/emitter.tsx +523 -0
  45. api/spec/packages/typespec-typescript/src/external-packages/zod.ts +12 -0
  46. api/spec/packages/typespec-typescript/src/http-status.ts +24 -0
  47. api/spec/packages/typespec-typescript/src/index.ts +6 -0
  48. api/spec/packages/typespec-typescript/src/input-variants.ts +118 -0
  49. api/spec/packages/typespec-typescript/src/interface-types.ts +280 -0
  50. api/spec/packages/typespec-typescript/src/lib.ts +72 -0
api/spec/packages/legacy/src/rest.tsp ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import "@typespec/rest";
2
+
3
+ namespace TypeSpec.Rest.Resource {
4
+ /**
5
+ * Resource update operation model.
6
+ * @template Resource The resource model to update with replace.
7
+ */
8
+ @friendlyName("{name}ReplaceUpdate", Resource)
9
+ model ResourceReplaceModel<Resource extends {}>
10
+ is UpdateableProperties<DefaultKeyVisibility<Resource, Lifecycle.Read>>;
11
+ }
12
+
13
+ namespace OpenMeter.Rest {
14
+ /**
15
+ * Resource create operation model.
16
+ * @template Resource The resource model to create.
17
+ */
18
+ @friendlyName("{name}Create", Resource)
19
+ @withVisibilityFilter(#{ all: #[Lifecycle.Create] })
20
+ model ResourceCreateModel<Resource extends {}> {
21
+ ...Resource;
22
+ }
23
+
24
+ /**
25
+ * Resource update operation model.
26
+ * @template Resource The resource model to partially update.
27
+ */
28
+ @friendlyName("{name}Update", Resource)
29
+ @withVisibilityFilter(#{ all: #[Lifecycle.Update] })
30
+ model ResourceUpdateModel<Resource extends {}> {
31
+ ...Resource;
32
+ }
33
+
34
+ /**
35
+ * Resource replace operation model.
36
+ * @template Resource The resource model to update with replace.
37
+ */
38
+ @friendlyName("{name}Replace", Resource)
39
+ @withVisibilityFilter(#{ any: #[Lifecycle.Create, Lifecycle.Update] })
40
+ model ResourceReplaceModel<Resource extends {}> {
41
+ ...Resource;
42
+ }
43
+ }
api/spec/packages/legacy/src/subjects.tsp ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import "@typespec/http";
2
+ import "@typespec/rest";
3
+ import "@typespec/openapi3";
4
+
5
+ using TypeSpec.Http;
6
+ using TypeSpec.OpenAPI;
7
+
8
+ namespace OpenMeter;
9
+
10
+ @route("/api/v1/subjects")
11
+ @tag("Subjects")
12
+ @friendlyName("Subjects")
13
+ interface SubjectsEndpoints {
14
+ /**
15
+ * List subjects.
16
+ *
17
+ * ⚠️ __Deprecated__: Subjects as managable entities are being depracated, use customers with subject key usage attribution instead.
18
+ */
19
+ #deprecated "Subjects as entities are being depracated, use customers instead"
20
+ #suppress "deprecated" "Subjects APIs will be removed on December 1st, 2025"
21
+ @get
22
+ @operationId("listSubjects")
23
+ @summary("List subjects")
24
+ list(): Subject[] | CommonErrors;
25
+
26
+ /**
27
+ * Get subject by ID or key.
28
+ *
29
+ * ⚠️ __Deprecated__: Subjects as managable entities are being depracated, use customers with subject key usage attribution instead.
30
+ */
31
+ #deprecated "Subjects as entities are being depracated, use customers instead"
32
+ #suppress "deprecated" "Subjects APIs will be removed on December 1st, 2025"
33
+ @get
34
+ @operationId("getSubject")
35
+ @summary("Get subject")
36
+ get(@path subjectIdOrKey: string): Subject | NotFoundError | CommonErrors;
37
+
38
+ /**
39
+ * Upserts a subject. Creates or updates subject.
40
+ *
41
+ * If the subject doesn't exist, it will be created.
42
+ * If the subject exists, it will be partially updated with the provided fields.
43
+ *
44
+ * ⚠️ __Deprecated__: Subjects as managable entities are being depracated, use customers with subject key usage attribution instead.
45
+ */
46
+ #deprecated "Subjects as entities are being depracated, use customers instead"
47
+ #suppress "deprecated" "Subjects APIs will be removed on December 1st, 2025"
48
+ @post
49
+ @operationId("upsertSubject")
50
+ @summary("Upsert subject")
51
+ upsert(@body subject: SubjectUpsert[]): Subject[] | CommonErrors;
52
+
53
+ /**
54
+ * Delete subject by ID or key.
55
+ *
56
+ * ⚠️ __Deprecated__: Subjects as managable entities are being depracated, use customers with subject key usage attribution instead.
57
+ */
58
+ #deprecated "Subjects as entities are being depracated, use customers instead"
59
+ #suppress "deprecated" "Subjects APIs will be removed on December 1st, 2025"
60
+ @delete
61
+ @operationId("deleteSubject")
62
+ @summary("Delete subject")
63
+ delete(@path subjectIdOrKey: string): void | CommonErrors;
64
+ }
65
+
66
+ /**
67
+ * A subject is a unique identifier for a usage attribution by its key.
68
+ * Subjects only exist in the concept of metering.
69
+ * Subjects are optional to create and work as an enrichment for the subject key like displayName, metadata, etc.
70
+ * Subjects are useful when you are reporting usage events with your own database ID but want to enrich the subject with a human-readable name or metadata.
71
+ * For most use cases, a subject is equivalent to a customer.
72
+ *
73
+ * ⚠️ __Deprecated__: Subjects as managable entities are being depracated, use customers with subject key usage attribution instead.
74
+ */
75
+ #deprecated "Use customers instead"
76
+ #suppress "deprecated" "Subjects APIs will be removed on December 1st, 2025"
77
+ @friendlyName("Subject")
78
+ @example(#{
79
+ createdAt: DateTime.fromISO("2025-01-01T01:01:01.001Z"),
80
+ updatedAt: DateTime.fromISO("2025-02-01T01:01:01.001Z"),
81
+ deletedAt: DateTime.fromISO("2025-03-01T01:01:01.001Z"),
82
+ id: "01G65Z755AFWAKHE12NY0CQ9FH",
83
+ key: "customer-id",
84
+ displayName: "Customer Name",
85
+ metadata: #{ hubspotId: "123456" },
86
+ stripeCustomerId: "cus_JMOlctsKV8",
87
+ })
88
+ model Subject {
89
+ ...ResourceTimestamps;
90
+
91
+ // Validator doesn't obey required for readOnly properties
92
+ // See: https://github.com/stoplightio/spectral/issues/1274
93
+
94
+ /**
95
+ * A unique identifier for the subject.
96
+ */
97
+ @visibility(Lifecycle.Read)
98
+ @example("01G65Z755AFWAKHE12NY0CQ9FH")
99
+ id: ULID;
100
+
101
+ /**
102
+ * A unique, human-readable identifier for the subject.
103
+ * This is typically a database ID or a customer key.
104
+ */
105
+ @example("customer-db-id-123")
106
+ key: string;
107
+
108
+ /**
109
+ * A human-readable display name for the subject.
110
+ */
111
+ @example("Customer Name")
112
+ displayName?: string | null;
113
+
114
+ /**
115
+ * Metadata for the subject.
116
+ */
117
+ @example(#{ hubspotId: "123456" })
118
+ metadata?: Record<unknown> | null;
119
+
120
+ /**
121
+ * The start of the current period for the subject.
122
+ */
123
+ #deprecated "Use Stripe App instead"
124
+ @example(DateTime.fromISO("2023-01-01T00:00:00Z"))
125
+ currentPeriodStart?: DateTime;
126
+
127
+ /**
128
+ * The end of the current period for the subject.
129
+ */
130
+ #deprecated "Use Stripe App instead"
131
+ @example(DateTime.fromISO("2023-02-01T00:00:00Z"))
132
+ currentPeriodEnd?: DateTime;
133
+
134
+ /**
135
+ * The Stripe customer ID for the subject.
136
+ */
137
+ #deprecated "Use customer app instead"
138
+ @example("cus_JMOlctsKV8")
139
+ stripeCustomerId?: string | null;
140
+ }
141
+
142
+ /**
143
+ * A subject is a unique identifier for a user or entity.
144
+ *
145
+ * ⚠️ __Deprecated__: Subjects as managable entities are being depracated, use customers with subject key usage attribution instead.
146
+ */
147
+ #deprecated "Use customers instead"
148
+ #suppress "deprecated" "Subjects APIs will be removed on December 1st, 2025"
149
+ @friendlyName("SubjectUpsert")
150
+ @example(#{
151
+ key: "customer-id",
152
+ displayName: "Customer Name",
153
+ metadata: #{ hubspotId: "123456" },
154
+ stripeCustomerId: "cus_JMOlctsKV8",
155
+ })
156
+ model SubjectUpsert is TypeSpec.Rest.Resource.ResourceCreateModel<Subject>;
api/spec/packages/legacy/src/types.tsp ADDED
@@ -0,0 +1,447 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import "@typespec/openapi3";
2
+
3
+ using TypeSpec.OpenAPI;
4
+
5
+ /**
6
+ * ULID (Universally Unique Lexicographically Sortable Identifier).
7
+ */
8
+ // See: https://github.com/ulid/spec/issues/94
9
+ @pattern("^[0-7][0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{25}$")
10
+ @example("01G65Z755AFWAKHE12NY0CQ9FH")
11
+ @extension("x-inline", true)
12
+ scalar ULID extends string;
13
+
14
+ /**
15
+ * A key is a unique string that is used to identify a resource.
16
+ */
17
+ @pattern(
18
+ "^[a-z0-9]+(?:_[a-z0-9]+)*$",
19
+ "Must start with a lowercase letter or a number. Can contain lowercase letters, numbers, and underscores."
20
+ )
21
+ @minLength(1)
22
+ @maxLength(64)
23
+ @extension("x-inline", true)
24
+ scalar Key extends string;
25
+
26
+ /**
27
+ * ExternalKey is a looser version of key.
28
+ */
29
+ @maxLength(256)
30
+ @minLength(1)
31
+ @extension("x-inline", true)
32
+ scalar ExternalKey extends string;
33
+
34
+ /**
35
+ * ULID (Universally Unique Lexicographically Sortable Identifier).
36
+ * A key is a unique string that is used to identify a resource.
37
+ *
38
+ * TODO: this is a temporary solution to support both ULID and Key in the same spec for codegen.
39
+ */
40
+ @pattern("^[a-z0-9]+(?:_[a-z0-9]+)*$|^[0-7][0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{25}$")
41
+ @minLength(1)
42
+ @maxLength(64)
43
+ @extension("x-inline", true)
44
+ scalar ULIDOrKey extends string;
45
+
46
+ /**
47
+ * ULID (Universally Unique Lexicographically Sortable Identifier) or external unique key.
48
+ */
49
+ @extension("x-go-type", "string")
50
+ @friendlyName("ULIDOrExternalKey")
51
+ union ULIDOrExternalKey {
52
+ id: ULID,
53
+ key: ExternalKey,
54
+ }
55
+
56
+ // NOTE (andras): key format enforcement isn't supported by TypeSpec (patternProperties). See: https://github.com/microsoft/typespec/discussions/1626
57
+ // TODO: decide if we want to use the generated Metadata type instead and update code to use it
58
+ /**
59
+ * Set of key-value pairs.
60
+ * Metadata can be used to store additional information about a resource.
61
+ */
62
+ @extension("x-go-type", "map[string]string")
63
+ @example(#{ externalId: "019142cc-a016-796a-8113-1a942fecd26d" })
64
+ @friendlyName("Metadata")
65
+ model Metadata {
66
+ ...Record<string>;
67
+ }
68
+
69
+ /**
70
+ * [RFC3339](https://tools.ietf.org/html/rfc3339) formatted date-time string in UTC.
71
+ */
72
+ @encode(DateTimeKnownEncoding.rfc3339)
73
+ @example(DateTime.fromISO("2023-01-01T01:01:01.001Z"))
74
+ @extension("x-inline", true)
75
+ scalar DateTime extends utcDateTime;
76
+
77
+ /**
78
+ * Represents a resource with a key.
79
+ */
80
+ @friendlyName("KeyedResource")
81
+ model Keyed {
82
+ /**
83
+ * A locally unique identifier for the resource.
84
+ */
85
+ key: Key;
86
+ }
87
+
88
+ /**
89
+ * Represents a resource with a unique key.
90
+ */
91
+ @friendlyName("UniqueResource")
92
+ model UniqueResource {
93
+ ...Resource;
94
+
95
+ /**
96
+ * A semi-unique identifier for the resource.
97
+ */
98
+ @visibility(Lifecycle.Read, Lifecycle.Create)
99
+ @summary("Key")
100
+ key: Key;
101
+ }
102
+
103
+ /**
104
+ * IDResource is a resouce with an ID.
105
+ */
106
+ // NOTE: this can be used to have a type, that we can later replace with the expanded type if needed without
107
+ // breaking api compatibility
108
+ @friendlyName("IDResource")
109
+ model IDResource {
110
+ /**
111
+ * A unique identifier for the resource.
112
+ */
113
+ @visibility(Lifecycle.Read)
114
+ @example("01G65Z755AFWAKHE12NY0CQ9FH")
115
+ @summary("ID")
116
+ id: ULID;
117
+ }
118
+
119
+ /**
120
+ * Represents common fields of resources.
121
+ */
122
+ @friendlyName("Resource")
123
+ model Resource {
124
+ /**
125
+ * A unique identifier for the resource.
126
+ */
127
+ @visibility(Lifecycle.Read)
128
+ @example("01G65Z755AFWAKHE12NY0CQ9FH")
129
+ @summary("ID")
130
+ id: ULID;
131
+
132
+ /**
133
+ * Human-readable name for the resource. Between 1 and 256 characters.
134
+ */
135
+ @summary("Display name")
136
+ @minLength(1)
137
+ @maxLength(256)
138
+ name: string;
139
+
140
+ /**
141
+ * Optional description of the resource. Maximum 1024 characters.
142
+ */
143
+ @maxLength(1024)
144
+ @summary("Description")
145
+ description?: string;
146
+
147
+ /**
148
+ * Additional metadata for the resource.
149
+ */
150
+ @summary("Metadata")
151
+ metadata?: Metadata | null;
152
+
153
+ ...ResourceTimestamps;
154
+ }
155
+
156
+ /**
157
+ * Represents resources that can be cadenced, have scheduled activity changes.
158
+ */
159
+ @friendlyName("CadencedResource")
160
+ model CadencedResource {
161
+ /**
162
+ * The cadence start of the resource.
163
+ */
164
+ activeFrom: DateTime;
165
+
166
+ /**
167
+ * The cadence end of the resource.
168
+ */
169
+ activeTo?: DateTime;
170
+ }
171
+
172
+ /**
173
+ * Collects the timestamps used by all resources.
174
+ */
175
+ @friendlyName("Timestamps")
176
+ model ResourceTimestamps {
177
+ /**
178
+ * Timestamp of when the resource was created.
179
+ */
180
+ @summary("Creation Time")
181
+ @visibility(Lifecycle.Read)
182
+ @example(DateTime.fromISO("2024-01-01T01:01:01.001Z"))
183
+ createdAt: DateTime;
184
+
185
+ /**
186
+ * Timestamp of when the resource was last updated.
187
+ */
188
+ @summary("Last Update Time")
189
+ @visibility(Lifecycle.Read)
190
+ @example(DateTime.fromISO("2024-01-01T01:01:01.001Z"))
191
+ updatedAt: DateTime;
192
+
193
+ /**
194
+ * Timestamp of when the resource was permanently deleted.
195
+ */
196
+ @summary("Deletion Time")
197
+ @visibility(Lifecycle.Read)
198
+ @example(DateTime.fromISO("2024-01-01T01:01:01.001Z"))
199
+ deletedAt?: DateTime;
200
+ }
201
+
202
+ /**
203
+ * Represents common fields of resources that can be archived.
204
+ */
205
+ @friendlyName("Archiveable")
206
+ model Archiveable {
207
+ /**
208
+ * Timestamp of when the resource was archived.
209
+ */
210
+ @summary("Archival Time")
211
+ @visibility(Lifecycle.Read)
212
+ archivedAt?: DateTime;
213
+ }
214
+
215
+ // This is a reasonably good RE2-safe ISO8601 duration pattern that allows fractional components (though overly permissive)
216
+ // It matches patterns such as
217
+ // - P1Y
218
+ // - P1.5Y
219
+ // - P1Y2M1D
220
+ // - P1Y2MT <= invalid: must have time component defined after T
221
+ // - P1Y2MT1H2S
222
+ // - P1Y2.3MT1.4H2S <= invalid: spec only allows for a single fractional component
223
+ //
224
+ // A stricter alternative that doesn't allow fractional components is: ^P(?:\d+Y)?(?:\d+M)?(?:\d+W)?(?:\d+D)?(?:T(?:\d+H)?(?:\d+M)?(?:\d+S)?)?$
225
+ // ^before using the stricter pattern make sure to double escape the backslashes
226
+ @pattern("^P(?:\\d+(?:\\.\\d+)?Y)?(?:\\d+(?:\\.\\d+)?M)?(?:\\d+(?:\\.\\d+)?W)?(?:\\d+(?:\\.\\d+)?D)?(?:T(?:\\d+(?:\\.\\d+)?H)?(?:\\d+(?:\\.\\d+)?M)?(?:\\d+(?:\\.\\d+)?S)?)?$")
227
+ @extension("x-inline", true)
228
+ scalar ISO8601Duration extends string;
229
+
230
+ /**
231
+ * Period duration for the recurrence
232
+ */
233
+ @friendlyName("RecurringPeriodInterval")
234
+ union RecurringPeriodInterval {
235
+ ISO8601Duration,
236
+ RecurringPeriodIntervalEnum,
237
+ }
238
+
239
+ /**
240
+ * The unit of time for the interval.
241
+ * One of: `day`, `week`, `month`, or `year`.
242
+ */
243
+ @friendlyName("RecurringPeriodIntervalEnum")
244
+ enum RecurringPeriodIntervalEnum {
245
+ #suppress "@openmeter/api-spec-legacy/casing" "Use existing values"
246
+ Day: "DAY",
247
+ #suppress "@openmeter/api-spec-legacy/casing" "Use existing values"
248
+ Week: "WEEK",
249
+ #suppress "@openmeter/api-spec-legacy/casing" "Use existing values"
250
+ Month: "MONTH",
251
+ #suppress "@openmeter/api-spec-legacy/casing" "Use existing values"
252
+ Year: "YEAR",
253
+ }
254
+
255
+ /**
256
+ * Recurring period with an interval and an anchor.
257
+ */
258
+ #deprecated "Use RecurringPeriodV2 instead"
259
+ @example(#{
260
+ interval: RecurringPeriodIntervalEnum.Day,
261
+ intervalISO: duration.fromISO("P1D"),
262
+ anchor: DateTime.fromISO("2023-01-01T01:01:01.001Z"),
263
+ })
264
+ @friendlyName("RecurringPeriod")
265
+ model RecurringPeriod {
266
+ ...RecurringPeriodV2;
267
+
268
+ /**
269
+ * The unit of time for the interval in ISO8601 format.
270
+ */
271
+ @encode(DurationKnownEncoding.ISO8601)
272
+ intervalISO: duration;
273
+ }
274
+
275
+ /**
276
+ * Recurring period with an interval and an anchor.
277
+ */
278
+ @friendlyName("RecurringPeriodV2")
279
+ model RecurringPeriodV2 {
280
+ /**
281
+ * The unit of time for the interval. Heuristically maps ISO duraitons to enum values or returns the ISO duration.
282
+ */
283
+ @summary("Interval")
284
+ interval: RecurringPeriodInterval;
285
+
286
+ /**
287
+ * A date-time anchor to base the recurring period on.
288
+ */
289
+ @summary("Anchor time")
290
+ @example(DateTime.fromISO("2023-01-01T01:01:01.001Z"))
291
+ anchor: DateTime;
292
+ }
293
+
294
+ /**
295
+ * Recurring period with an interval and an anchor.
296
+ */
297
+ @example(#{
298
+ interval: RecurringPeriodIntervalEnum.Day,
299
+ anchor: DateTime.fromISO("2023-01-01T01:01:01.001Z"),
300
+ })
301
+ @friendlyName("RecurringPeriodCreateInput")
302
+ model RecurringPeriodCreateInput {
303
+ /**
304
+ * The unit of time for the interval.
305
+ */
306
+ @summary("Interval")
307
+ interval: RecurringPeriodInterval;
308
+
309
+ /**
310
+ * A date-time anchor to base the recurring period on.
311
+ */
312
+ @summary("Anchor time")
313
+ @example(DateTime.fromISO("2023-01-01T01:01:01.001Z"))
314
+ anchor?: DateTime;
315
+ }
316
+
317
+ /**
318
+ * A period with a start and end time.
319
+ */
320
+ @friendlyName("Period")
321
+ model Period {
322
+ /**
323
+ * Period start time.
324
+ */
325
+ @example(DateTime.fromISO("2023-01-01T01:01:01.001Z"))
326
+ from: DateTime;
327
+
328
+ /**
329
+ * Period end time.
330
+ */
331
+ @example(DateTime.fromISO("2023-02-01T01:01:01.001Z"))
332
+ to: DateTime;
333
+ }
334
+
335
+ /**
336
+ * Three-letter [ISO4217](https://www.iso.org/iso-4217-currency-codes.html) currency code.
337
+ * Custom three-letter currency codes are also supported for convenience.
338
+ */
339
+ @pattern("^[A-Z]{3}$")
340
+ // TODO: add helpers for currency database
341
+ @friendlyName("CurrencyCode")
342
+ @minLength(3)
343
+ @maxLength(3)
344
+ @example("USD")
345
+ scalar CurrencyCode extends string;
346
+
347
+ /**
348
+ * [ISO 3166-1](https://www.iso.org/iso-3166-country-codes.html) alpha-2 country code.
349
+ * Custom two-letter country codes are also supported for convenience.
350
+ */
351
+ @pattern("^[A-Z]{2}$")
352
+ @friendlyName("CountryCode")
353
+ @minLength(2)
354
+ @maxLength(2)
355
+ @example("US")
356
+ scalar CountryCode extends string;
357
+
358
+ /**
359
+ * Address
360
+ */
361
+ @friendlyName("Address")
362
+ model Address {
363
+ /**
364
+ * Country code in [ISO 3166-1](https://www.iso.org/iso-3166-country-codes.html) alpha-2 format.
365
+ */
366
+ country?: CountryCode;
367
+
368
+ /**
369
+ * Postal code.
370
+ */
371
+ postalCode?: string;
372
+
373
+ /**
374
+ * State or province.
375
+ */
376
+ state?: string;
377
+
378
+ /**
379
+ * City.
380
+ */
381
+ city?: string;
382
+
383
+ /**
384
+ * First line of the address.
385
+ */
386
+ line1?: string;
387
+
388
+ /**
389
+ * Second line of the address.
390
+ */
391
+ line2?: string;
392
+
393
+ /**
394
+ * Phone number.
395
+ */
396
+ phoneNumber?: string;
397
+ }
398
+
399
+ /**
400
+ * Meta object to generate create/update request from type by omitting readonly properties.
401
+ */
402
+ @friendlyName("{name}Request", T)
403
+ model Request<T, Keys extends string> {
404
+ ...OmitProperties<T, Keys>;
405
+ }
406
+
407
+ /**
408
+ * Set of key-value pairs managed by the system. Cannot be modified by user.
409
+ */
410
+ @example(#{ externalId: "019142cc-a016-796a-8113-1a942fecd26d" })
411
+ @friendlyName("Annotations")
412
+ model Annotations {
413
+ ...Record<unknown>;
414
+ }
415
+
416
+ /**
417
+ * Numeric represents an arbitrary precision number.
418
+ */
419
+ @pattern("^\\-?[0-9]+(\\.[0-9]+)?$")
420
+ @friendlyName("Numeric")
421
+ scalar Numeric extends string;
422
+ alias Money = Numeric;
423
+
424
+ /**
425
+ * Numeric representation of a percentage
426
+ *
427
+ * 50% is represented as 50
428
+ */
429
+ @example(50)
430
+ @friendlyName("Percentage")
431
+ @extension("x-go-type", "models.Percentage")
432
+ @extension("x-go-package", "github.com/openmeterio/openmeter/pkg/models")
433
+ scalar Percentage extends float64;
434
+
435
+ /**
436
+ * Unit describes how the quantity of the product should be interpreted.
437
+ */
438
+ @friendlyName("Unit")
439
+ scalar Unit extends string;
440
+
441
+ /**
442
+ * TaxIdentificationCode is a normalized tax code shown on the original identity document.
443
+ */
444
+ @minLength(1)
445
+ @maxLength(32)
446
+ @friendlyName("BillingTaxIdentificationCode")
447
+ scalar TaxIdentificationCode extends string;
api/spec/packages/legacy/tspconfig.client.yaml ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ emit:
2
+ - '@typespec/http-client-python'
3
+ parameters:
4
+ base-dir:
5
+ default: '{cwd}'
6
+ client-dir:
7
+ default: '{cwd}/../../../client'
8
+ output-dir: '{base-dir}'
9
+ options:
10
+ '@typespec/http-client-python':
11
+ emitter-output-dir: '{client-dir}/python'
12
+ package-name: openmeter
13
+ generation-subdir: _generated
14
+ namespace: openmeter
15
+ package-version: '0.0.0'
16
+ generate-packaging-files: false
17
+ license:
18
+ name: Apache 2.0
19
+ linter:
20
+ extends:
21
+ - '@openmeter/api-spec-legacy/all'
api/spec/packages/legacy/tspconfig.yaml ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ emit:
2
+ - '@typespec/openapi3'
3
+ parameters:
4
+ base-dir:
5
+ default: '{cwd}'
6
+ client-dir:
7
+ default: '{cwd}/../client'
8
+ output-dir: '{base-dir}'
9
+ options:
10
+ '@typespec/openapi3':
11
+ emitter-output-dir: '{output-dir}/output'
12
+ linter:
13
+ extends:
14
+ - '@openmeter/api-spec-legacy/all'
api/spec/packages/typespec-go/.gitignore ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ dist/
2
+ tsconfig.tsbuildinfo
api/spec/packages/typespec-go/README.md ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # @openmeter/typespec-go
2
+
3
+ A TypeSpec **emitter** that generates the OpenMeter **Go** SDK from the AIP
4
+ TypeSpec specs, mirroring [`@openmeter/typespec-typescript`](../typespec-typescript)
5
+ but targeting Go.
6
+
7
+ ## Output
8
+
9
+ The emitter writes to `api/v3/client` at the **repo root** (not a sibling of
10
+ this package): a single flat `package openmeter` that is its own nested Go
11
+ module, `github.com/openmeterio/openmeter/api/v3/client`. The output directory
12
+ is set in [`packages/aip/tspconfig.yaml`](../aip/tspconfig.yaml) via
13
+ `emitter-output-dir: '{output-dir}/../../../v3/client'`.
14
+
15
+ The generated files are fully regenerable — **never hand-edit them**. Change
16
+ the emitter (for spec-derived files) or `src/runtime-templates.ts` (for the
17
+ static runtime files), then regenerate. The output cleaner wipes previously
18
+ generated files before emission but preserves `*_test.go` and `testdata/`:
19
+ hand-written wire tests live in `api/v3/client` and survive regeneration.
20
+
21
+ ## How it works
22
+
23
+ `src/emitter.tsx` discovers and groups HTTP operations, validates codec/name
24
+ exhaustiveness, computes payload-context reachability (read vs input model
25
+ projections), and renders models, services, the root client, and the package
26
+ `README.md` from one operation IR — so documented call paths and routes always
27
+ match the emitted SDK. Unions retain their raw JSON for forward-compatible
28
+ round-tripping. The wire format is snake_case and the Go surface is PascalCase
29
+ fields with `json:"snake_case"` tags, so — unlike the TS emitter — there is
30
+ **no casing translation layer**. See [PLAN.md](./PLAN.md) for the design
31
+ history and the full architecture.
32
+
33
+ ## Options
34
+
35
+ Declared in `src/lib.ts`, configured in `packages/aip/tspconfig.yaml`:
36
+
37
+ | Option | Required | Purpose |
38
+ | --------------------- | -------- | -------------------------------------------------------------------------------------- |
39
+ | `module-path` | yes | Go module path of the generated SDK (`github.com/openmeterio/openmeter/api/v3/client`) |
40
+ | `package-name` | yes | Go package name (`openmeter`) |
41
+ | `sdk-version` | no | Fallback version used when Go build info is unavailable; defaults to `0.0.0-dev` |
42
+ | `include-services` | no | Service namespaces to emit (`['OpenMeter']`); all services when omitted |
43
+ | `strip-name-prefixes` | no | PascalCase type-name prefixes stripped when unambiguous |
44
+ | `include-resources` | no | Operation groups to emit; every discovered group when omitted |
45
+ | `readme-note` | no | Markdown callout inserted after the generated README intro |
46
+ | `go-version` | no | Stamped into the go.mod `go` directive; defaults to `1.23`, the generated code's floor |
47
+
48
+ ## Commands
49
+
50
+ | Task | Command |
51
+ | ------------------- | -------------------------------------------------------------------------------------------------------- |
52
+ | Build the emitter | `pnpm build` (`alloy build`) |
53
+ | Watch | `pnpm watch` |
54
+ | Typecheck | `pnpm typecheck` |
55
+ | Emitter tests | `pnpm test` (vitest over `test/`) |
56
+ | Emitter checks | `pnpm check` (typecheck + tests) |
57
+ | Regenerate the SDK | `pnpm --filter @openmeter/api-spec-aip run generate` (or `make gen-api`, repo root) |
58
+ | Generated SDK check | `make test-go-sdk` (repo root), or in `api/v3/client`: `go build ./... && go vet ./... && go test ./...` |
59
+
60
+ ## Wiring
61
+
62
+ Registered in [`packages/aip/tspconfig.yaml`](../aip/tspconfig.yaml) under
63
+ `emit:` and declared as a `workspace:*` devDependency of
64
+ `@openmeter/api-spec-aip` so `tsp` resolves it. One `pnpm generate` produces
65
+ the OpenAPI document and every SDK.
66
+
67
+ ## Releases
68
+
69
+ Releases are `api/v3/client/vX.Y.Z` git tags, gated by
70
+ `.github/workflows/release-go-sdk.yaml`. `Version` resolves at runtime via
71
+ `debug.ReadBuildInfo()` to the module version consumers pulled in through their
72
+ own `go.mod`, so no stamping commit is needed before tagging: push the paired
73
+ root and nested-module tags and the gate runs `make test-go-sdk` against them.
74
+ `sdk-version` in `packages/aip/tspconfig.yaml` only sets the fallback baked
75
+ into `option.go` for builds without resolvable module build info (the module
76
+ itself, replace directives, vendored trees).
api/spec/packages/typespec-go/package.json ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "@openmeter/typespec-go",
3
+ "version": "0.0.1",
4
+ "private": true,
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "default": "./dist/index.js"
11
+ }
12
+ },
13
+ "scripts": {
14
+ "build": "alloy build",
15
+ "watch": "alloy build --watch",
16
+ "typecheck": "tsc --noEmit",
17
+ "test": "pnpm run build && vitest --run",
18
+ "check": "pnpm run typecheck && pnpm run test"
19
+ },
20
+ "dependencies": {
21
+ "@alloy-js/core": "0.23.1",
22
+ "@alloy-js/go": "0.3.0",
23
+ "@typespec/compiler": "1.11.0",
24
+ "@typespec/emitter-framework": "0.17.0",
25
+ "@typespec/http": "1.11.0",
26
+ "@typespec/openapi": "1.11.0"
27
+ },
28
+ "devDependencies": {
29
+ "@alloy-js/cli": "0.23.0",
30
+ "@types/node": "25.9.2",
31
+ "typescript": "6.0.3",
32
+ "vitest": "4.1.8"
33
+ }
34
+ }
api/spec/packages/typespec-go/src/components/GoClient.tsx ADDED
@@ -0,0 +1,145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as ay from '@alloy-js/core'
2
+ import * as go from '@alloy-js/go'
3
+ import type { Refkey } from '@alloy-js/core'
4
+ import { strings, url } from '../stdlib.js'
5
+
6
+ export interface GoClientResource {
7
+ name: string
8
+ root: string
9
+ nestPath: string[]
10
+ serviceRefkey: Refkey
11
+ }
12
+
13
+ export interface GoClientProps {
14
+ clientRefkey: Refkey
15
+ resources: GoClientResource[]
16
+ }
17
+
18
+ export function GoClient({ clientRefkey, resources }: GoClientProps) {
19
+ const roots = resources.filter((resource) => resource.nestPath.length === 0)
20
+ const wiring = [...resources]
21
+ .sort((left, right) => left.nestPath.length - right.nestPath.length)
22
+ .map((resource) => {
23
+ const target = ['c', resource.root, ...resource.nestPath].join('.')
24
+ return `${target} = &${resource.name}Service{client: c}`
25
+ })
26
+ .join('\n')
27
+
28
+ const receiver = () => (
29
+ <go.FunctionReceiver
30
+ name="c"
31
+ type={
32
+ <go.Pointer>
33
+ <go.Reference refkey={clientRefkey} />
34
+ </go.Pointer>
35
+ }
36
+ />
37
+ )
38
+
39
+ return (
40
+ <ay.List joiner={'\n\n'}>
41
+ <go.StructTypeDeclaration name="Client" refkey={clientRefkey}>
42
+ <ay.List hardline>
43
+ <go.StructMember
44
+ name="baseURL"
45
+ type={<go.Pointer>{url.URL}</go.Pointer>}
46
+ />
47
+ <go.StructMember
48
+ name="httpClient"
49
+ type={<go.Pointer>{go.std.net.http.Client}</go.Pointer>}
50
+ />
51
+ <go.StructMember name="token" type="string" />
52
+ <go.StructMember name="userAgent" type="string" />
53
+ <ay.List hardline>
54
+ {roots.map((resource) => (
55
+ <go.StructMember
56
+ name={resource.root}
57
+ type={
58
+ <go.Pointer>
59
+ <go.Reference refkey={resource.serviceRefkey} />
60
+ </go.Pointer>
61
+ }
62
+ />
63
+ ))}
64
+ </ay.List>
65
+ </ay.List>
66
+ </go.StructTypeDeclaration>
67
+ <go.FunctionDeclaration
68
+ name="New"
69
+ parameters={[
70
+ { name: 'baseURL', type: 'string' },
71
+ { name: 'opts', type: 'Option', variadic: true },
72
+ ]}
73
+ returns={['*Client', 'error']}
74
+ >
75
+ {ay.code`
76
+ if baseURL == "" {
77
+ return nil, ${go.std.fmt.Errorf}("openmeter: baseURL is required")
78
+ }
79
+
80
+ u, err := ${url.Parse}(baseURL)
81
+ if err != nil {
82
+ return nil, ${go.std.fmt.Errorf}("openmeter: invalid baseURL %q: %w", baseURL, err)
83
+ }
84
+
85
+ if u.Scheme == "" || u.Host == "" {
86
+ return nil, ${go.std.fmt.Errorf}("openmeter: baseURL %q must be absolute (scheme and host)", baseURL)
87
+ }
88
+
89
+ c := &Client{
90
+ baseURL: u,
91
+ userAgent: defaultUserAgent,
92
+ }
93
+
94
+ for _, opt := range opts {
95
+ opt(c)
96
+ }
97
+
98
+ if c.httpClient == nil {
99
+ c.httpClient = defaultHTTPClient()
100
+ }
101
+
102
+ ${wiring}
103
+
104
+ return c, nil
105
+ `}
106
+ </go.FunctionDeclaration>
107
+ <ay.List joiner={'\n'}>
108
+ {`// resolve joins the client base URL with an API path, preserving any base path
109
+ // prefix and base query present on the base URL. apiPath is parsed so percent
110
+ // escapes already present in a segment, such as an ID escaped by
111
+ // replacePathParam, are carried through on RawPath instead of being
112
+ // re-escaped.`}
113
+ <go.FunctionDeclaration
114
+ name="resolve"
115
+ receiver={receiver()}
116
+ parameters={[{ name: 'apiPath', type: 'string' }]}
117
+ returns={<go.Pointer>{url.URL}</go.Pointer>}
118
+ >
119
+ {ay.code`
120
+ base := *c.baseURL
121
+
122
+ if !${strings.HasSuffix}(base.Path, "/") {
123
+ base.Path += "/"
124
+ }
125
+
126
+ trimmed := ${strings.TrimPrefix}(apiPath, "/")
127
+ ref, err := ${url.Parse}(trimmed)
128
+ if err != nil {
129
+ ref = &${url.URL}{Path: trimmed}
130
+ }
131
+
132
+ resolved := base.ResolveReference(ref)
133
+ resolved.RawQuery = base.RawQuery
134
+ return resolved
135
+ `}
136
+ </go.FunctionDeclaration>
137
+ </ay.List>
138
+ {ay.code`
139
+ func replacePathParam(path, name, value string) string {
140
+ return ${strings.ReplaceAll}(path, "{" + name + "}", ${url.PathEscape}(value))
141
+ }
142
+ `}
143
+ </ay.List>
144
+ )
145
+ }
api/spec/packages/typespec-go/src/components/GoEnum.tsx ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as ay from '@alloy-js/core'
2
+ import * as go from '@alloy-js/go'
3
+ import { type Refkey } from '@alloy-js/core'
4
+ import { type Union, type UnionVariant } from '@typespec/compiler'
5
+ import { goExportedName } from '../go-types.js'
6
+
7
+ export interface GoEnumProps {
8
+ union: Union
9
+ name: string
10
+ refkey: Refkey
11
+ doc?: string
12
+ }
13
+
14
+ export function GoEnum({ union, name, refkey, doc }: GoEnumProps) {
15
+ const members = [...union.variants.values()].map((variant) => ({
16
+ name: variantName(variant),
17
+ value: variantValue(variant),
18
+ }))
19
+
20
+ return (
21
+ <>
22
+ <go.TypeDeclaration name={name} refkey={refkey} doc={doc}>
23
+ string
24
+ </go.TypeDeclaration>
25
+ {'\n\n'}
26
+ <go.VariableDeclarationGroup const>
27
+ {members.map((member) => (
28
+ <go.VariableDeclaration
29
+ name={`${name}${goExportedName(member.name)}`}
30
+ type={refkey}
31
+ >
32
+ {JSON.stringify(member.value)}
33
+ </go.VariableDeclaration>
34
+ ))}
35
+ </go.VariableDeclarationGroup>
36
+ {'\n\n'}
37
+ <go.FunctionDeclaration
38
+ name="Valid"
39
+ receiver={<go.FunctionReceiver name="value" type={name} />}
40
+ returns="bool"
41
+ >
42
+ {ay.code`
43
+ switch value {
44
+ case ${members
45
+ .map((member) => `${name}${goExportedName(member.name)}`)
46
+ .join(', ')}:
47
+ return true
48
+ default:
49
+ return false
50
+ }
51
+ `}
52
+ </go.FunctionDeclaration>
53
+ </>
54
+ )
55
+ }
56
+
57
+ function variantName(variant: UnionVariant): string {
58
+ return typeof variant.name === 'symbol' ? variantValue(variant) : variant.name
59
+ }
60
+
61
+ function variantValue(variant: UnionVariant): string {
62
+ if (variant.type.kind !== 'String') {
63
+ throw new Error(
64
+ `Go string enum variant ${String(variant.name)} is not a string literal`,
65
+ )
66
+ }
67
+
68
+ return variant.type.value
69
+ }
api/spec/packages/typespec-go/src/components/GoModels.tsx ADDED
@@ -0,0 +1,206 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as ay from '@alloy-js/core'
2
+ import * as go from '@alloy-js/go'
3
+ import { type Program, type Type, type Union } from '@typespec/compiler'
4
+ import { $ } from '@typespec/compiler/typekit'
5
+ import {
6
+ goFields,
7
+ goProjectionsOf,
8
+ goType,
9
+ nullableUnionElement,
10
+ optionalTypeName,
11
+ typeName,
12
+ type GoDeclarationPlan,
13
+ } from '../go-types.js'
14
+ import { GoEnum } from './GoEnum.js'
15
+ import { GoStruct } from './GoStruct.js'
16
+ import { GoTypeSpecEnum } from './GoTypeSpecEnum.js'
17
+ import { GoUnion } from './GoUnion.js'
18
+ import { isRuntimeBackedTypeName } from '../runtime-symbols.js'
19
+
20
+ export interface GoModelsProps {
21
+ program: Program
22
+ types: Set<Type>
23
+ }
24
+
25
+ export function GoModels({ program, types }: GoModelsProps) {
26
+ const typekit = $(program)
27
+ const projections = goProjectionsOf(program)
28
+ const emittedNames = new Map<string, Type>()
29
+ const declarations = [...types]
30
+ .filter(
31
+ (type) =>
32
+ type.kind !== 'Model' ||
33
+ (!typekit.array.is(type) && !typekit.record.is(type)),
34
+ )
35
+ .flatMap((type) => {
36
+ const name = optionalTypeName(program, type)
37
+ return name ? [{ name, type }] : []
38
+ })
39
+ .filter(({ name, type }) => !isRuntimeBackedTypeName(name, type.kind))
40
+ .filter(({ name, type }) => {
41
+ const existing = emittedNames.get(name)
42
+ if (!existing) {
43
+ emittedNames.set(name, type)
44
+ }
45
+ return !existing
46
+ })
47
+ // Code-point comparison, not localeCompare: the output is a committed
48
+ // artifact, and locale-dependent ordering would produce spurious diffs
49
+ // across machines.
50
+ .sort((left, right) =>
51
+ left.name < right.name ? -1 : left.name > right.name ? 1 : 0,
52
+ )
53
+
54
+ // Without a configured projection registry (unit tests), every model emits
55
+ // its read projection under the natural name.
56
+ const plannedDeclarations = (
57
+ type: Type,
58
+ name: string,
59
+ ): GoDeclarationPlan[] =>
60
+ projections
61
+ ? (projections.declarations.get(type) ?? [])
62
+ : [{ name, mode: 'read' }]
63
+
64
+ return (
65
+ <ay.List joiner={'\n\n'}>
66
+ {declarations.map(({ name, type }) => {
67
+ switch (type.kind) {
68
+ case 'Model': {
69
+ const doc = typekit.type.getDoc(type)
70
+ return (
71
+ <ay.List joiner={'\n\n'}>
72
+ {plannedDeclarations(type, name).map((declaration) => (
73
+ <GoStruct
74
+ name={declaration.name}
75
+ fields={goFields(program, type, {
76
+ mode: declaration.mode === 'input' ? 'input' : undefined,
77
+ })}
78
+ doc={doc}
79
+ />
80
+ ))}
81
+ </ay.List>
82
+ )
83
+ }
84
+ case 'Union':
85
+ return (
86
+ <ay.List joiner={'\n\n'}>
87
+ {plannedDeclarations(type, name).map((declaration) =>
88
+ renderUnion(program, type, declaration),
89
+ )}
90
+ </ay.List>
91
+ )
92
+ case 'Enum':
93
+ return (
94
+ <GoTypeSpecEnum
95
+ enumType={type}
96
+ name={name}
97
+ doc={typekit.type.getDoc(type)}
98
+ />
99
+ )
100
+ // Scalars never carry generated behavior and no emitted field
101
+ // references a scalar alias (fields use the underlying Go type),
102
+ // so declaring them would only add dead exported names.
103
+ case 'Scalar':
104
+ default:
105
+ return undefined
106
+ }
107
+ })}
108
+ </ay.List>
109
+ )
110
+ }
111
+
112
+ export function renderUnion(
113
+ program: Program,
114
+ union: Union,
115
+ declaration: GoDeclarationPlan,
116
+ ) {
117
+ const name = declaration.name
118
+ const mode = declaration.mode === 'input' ? ('input' as const) : undefined
119
+ const doc = $(program).type.getDoc(union)
120
+
121
+ if (nullableUnionElement(union)) {
122
+ return (
123
+ <go.TypeDeclaration name={name} alias doc={doc}>
124
+ {goType(program, union, { mode }).type}
125
+ </go.TypeDeclaration>
126
+ )
127
+ }
128
+
129
+ const variants = [...union.variants.values()]
130
+ if (
131
+ variants.length > 0 &&
132
+ variants.every((variant) => variant.type.kind === 'String')
133
+ ) {
134
+ return <GoEnum union={union} name={name} refkey={ay.refkey()} doc={doc} />
135
+ }
136
+
137
+ if (
138
+ variants.length > 0 &&
139
+ variants.every((variant) => isStringLike(program, variant.type))
140
+ ) {
141
+ return (
142
+ <go.TypeDeclaration name={name} doc={doc}>
143
+ string
144
+ </go.TypeDeclaration>
145
+ )
146
+ }
147
+
148
+ if (
149
+ variants.length > 0 &&
150
+ variants.every((variant) => variant.type.kind === 'Model')
151
+ ) {
152
+ return (
153
+ <GoUnion
154
+ program={program}
155
+ union={union}
156
+ name={name}
157
+ mode={mode}
158
+ doc={doc}
159
+ />
160
+ )
161
+ }
162
+
163
+ const concrete = variants.filter(
164
+ (variant) => variant.type.kind !== 'Intrinsic',
165
+ )
166
+ if (concrete.length === 1) {
167
+ return (
168
+ <go.TypeDeclaration name={name} alias doc={doc}>
169
+ {goType(program, concrete[0]!.type, { mode }).type}
170
+ </go.TypeDeclaration>
171
+ )
172
+ }
173
+
174
+ if (concrete.length > 1) {
175
+ return (
176
+ <GoUnion
177
+ program={program}
178
+ union={union}
179
+ name={name}
180
+ mode={mode}
181
+ doc={doc}
182
+ />
183
+ )
184
+ }
185
+
186
+ throw new Error(
187
+ `typespec-go: union ${typeName(program, union)} has no concrete variants representable in Go; give it at least one non-intrinsic variant or replace the union with the intended model before emitting it`,
188
+ )
189
+ }
190
+
191
+ export function isStringLike(program: Program, type: Type): boolean {
192
+ switch (type.kind) {
193
+ case 'String':
194
+ return true
195
+ case 'Scalar':
196
+ return goType(program, type).type === 'string'
197
+ case 'Enum':
198
+ return [...type.members.values()].every(
199
+ (member) => typeof (member.value ?? member.name) === 'string',
200
+ )
201
+ case 'EnumMember':
202
+ return typeof (type.value ?? type.name) === 'string'
203
+ default:
204
+ return false
205
+ }
206
+ }
api/spec/packages/typespec-go/src/components/GoResource.tsx ADDED
@@ -0,0 +1,1000 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as ay from '@alloy-js/core'
2
+ import * as go from '@alloy-js/go'
3
+ import type { Refkey } from '@alloy-js/core'
4
+ import {
5
+ resolveEncodedName,
6
+ walkPropertiesInherited,
7
+ type ModelProperty,
8
+ type Operation,
9
+ type Program,
10
+ type Type,
11
+ } from '@typespec/compiler'
12
+ import { $ } from '@typespec/compiler/typekit'
13
+ import { isHeader, isStatusCode } from '@typespec/http'
14
+ import {
15
+ goExportedName,
16
+ goType,
17
+ queryFilterKind,
18
+ queryScalarKind,
19
+ typeName,
20
+ } from '../go-types.js'
21
+ import {
22
+ describeOperations,
23
+ operationBaseName,
24
+ type GoOperation,
25
+ type GoParameter,
26
+ } from '../operations.js'
27
+ import { context, iter, strconv, strings, url } from '../stdlib.js'
28
+ import { GoStruct } from './GoStruct.js'
29
+
30
+ export interface GoResourceProps {
31
+ program: Program
32
+ resource: string
33
+ serviceName: string
34
+ nestPath: string[]
35
+ operations: Operation[]
36
+ bodyOverrides: Map<string, Type>
37
+ serviceRefkey: Refkey
38
+ children: Array<{ name: string; serviceRefkey: Refkey }>
39
+ }
40
+
41
+ export function GoResource({
42
+ program,
43
+ resource,
44
+ serviceName,
45
+ nestPath,
46
+ operations,
47
+ bodyOverrides,
48
+ serviceRefkey,
49
+ children,
50
+ }: GoResourceProps) {
51
+ const described = describeOperations(
52
+ program,
53
+ resource,
54
+ operations,
55
+ bodyOverrides,
56
+ nestPath,
57
+ )
58
+ const paramsNames = resolveListParamsNames(program, described)
59
+ const params = listParameterDeclarations(program, described, paramsNames)
60
+
61
+ return (
62
+ <ay.List joiner={'\n\n'}>
63
+ <go.StructTypeDeclaration
64
+ name={`${goExportedName(serviceName)}Service`}
65
+ refkey={serviceRefkey}
66
+ >
67
+ <ay.List hardline>
68
+ <go.StructMember name="client" type="*Client" />
69
+ <ay.List hardline>
70
+ {children.map((child) => (
71
+ <go.StructMember
72
+ name={goExportedName(child.name)}
73
+ type={
74
+ <go.Pointer>
75
+ <go.Reference refkey={child.serviceRefkey} />
76
+ </go.Pointer>
77
+ }
78
+ />
79
+ ))}
80
+ </ay.List>
81
+ </ay.List>
82
+ </go.StructTypeDeclaration>
83
+ {params}
84
+ <ay.List>
85
+ {described.flatMap((operation) => [
86
+ <OperationMethod
87
+ program={program}
88
+ operation={operation}
89
+ serviceRefkey={serviceRefkey}
90
+ paramsNames={paramsNames}
91
+ />,
92
+ '\n\n',
93
+ operation.pagination &&
94
+ isPlainPageEnvelope(program, operation.response) ? (
95
+ <>
96
+ <ListAllMethod
97
+ program={program}
98
+ operation={operation}
99
+ serviceRefkey={serviceRefkey}
100
+ paramsNames={paramsNames}
101
+ />
102
+ {'\n\n'}
103
+ </>
104
+ ) : undefined,
105
+ isTextResponse(operation) ? (
106
+ <>
107
+ <StreamMethod
108
+ program={program}
109
+ operation={operation}
110
+ serviceRefkey={serviceRefkey}
111
+ paramsNames={paramsNames}
112
+ />
113
+ {'\n\n'}
114
+ </>
115
+ ) : undefined,
116
+ ])}
117
+ </ay.List>
118
+ </ay.List>
119
+ )
120
+ }
121
+
122
+ function OperationMethod({
123
+ program,
124
+ operation,
125
+ serviceRefkey,
126
+ paramsNames,
127
+ }: {
128
+ program: Program
129
+ operation: GoOperation
130
+ serviceRefkey: Refkey
131
+ paramsNames: Map<GoOperation, string>
132
+ }) {
133
+ // goType resolves the read-side reference name, honoring structural-dedupe
134
+ // aliases; typeName alone would reference a collapsed declaration.
135
+ const responseName = operation.response
136
+ ? readReferenceName(program, operation.response)
137
+ : undefined
138
+ const textResponse = isTextResponse(operation)
139
+ const parameters = methodParameters(program, operation, paramsNames)
140
+ const path = pathCode(operation)
141
+ const requestBody = operation.body
142
+ ? operation.bodyOptional
143
+ ? 'optionalBody(request)'
144
+ : 'request'
145
+ : 'nil'
146
+ const query = operation.queryParams.length > 0 ? 'params.values()' : 'nil'
147
+ const requestContentType = operation.body
148
+ ? JSON.stringify(operation.requestContentType ?? 'application/json')
149
+ : '""'
150
+ const accept = JSON.stringify(
151
+ operation.responseContentType ??
152
+ (operation.response ? 'application/json' : ''),
153
+ )
154
+ const returns = textResponse
155
+ ? ['[]byte', 'error']
156
+ : responseName
157
+ ? [`*${responseName}`, 'error']
158
+ : 'error'
159
+
160
+ return (
161
+ <go.FunctionDeclaration
162
+ name={goExportedName(operation.methodName)}
163
+ receiver={serviceReceiver(serviceRefkey)}
164
+ parameters={parameters}
165
+ returns={returns}
166
+ doc={$(program).type.getDoc(operation.operation)}
167
+ >
168
+ {textResponse
169
+ ? ay.code`
170
+ ${path}
171
+
172
+ req, err := s.client.newRequestWithContentType(ctx, ${httpMethod(operation.verb)}, path, ${query}, ${requestBody}, ${requestContentType}, ${accept})
173
+ if err != nil {
174
+ return nil, err
175
+ }
176
+
177
+ return s.client.doRaw(req)
178
+ `
179
+ : responseName
180
+ ? ay.code`
181
+ ${path}
182
+
183
+ req, err := s.client.newRequestWithContentType(ctx, ${httpMethod(operation.verb)}, path, ${query}, ${requestBody}, ${requestContentType}, ${accept})
184
+ if err != nil {
185
+ return nil, err
186
+ }
187
+
188
+ var out ${responseName}
189
+ if err := s.client.doJSON(req, &out); err != nil {
190
+ return nil, err
191
+ }
192
+
193
+ return &out, nil
194
+ `
195
+ : ay.code`
196
+ ${path}
197
+
198
+ req, err := s.client.newRequestWithContentType(ctx, ${httpMethod(operation.verb)}, path, ${query}, ${requestBody}, ${requestContentType}, ${accept})
199
+ if err != nil {
200
+ return err
201
+ }
202
+
203
+ _, err = s.client.doRaw(req)
204
+ return err
205
+ `}
206
+ </go.FunctionDeclaration>
207
+ )
208
+ }
209
+
210
+ function ListAllMethod({
211
+ program,
212
+ operation,
213
+ serviceRefkey,
214
+ paramsNames,
215
+ }: {
216
+ program: Program
217
+ operation: GoOperation
218
+ serviceRefkey: Refkey
219
+ paramsNames: Map<GoOperation, string>
220
+ }) {
221
+ if (operation.pagination === 'cursor') {
222
+ return (
223
+ <CursorListAllMethod
224
+ program={program}
225
+ operation={operation}
226
+ serviceRefkey={serviceRefkey}
227
+ paramsNames={paramsNames}
228
+ />
229
+ )
230
+ }
231
+
232
+ const element = pageElement(program, operation.response!)
233
+ const listArguments = [
234
+ ...operation.pathParams.map((parameter) => localName(parameter.name)),
235
+ ...(operation.body ? ['request'] : []),
236
+ 'pageParams',
237
+ ].join(', ')
238
+
239
+ return (
240
+ <go.FunctionDeclaration
241
+ name={`${goExportedName(operation.methodName)}All`}
242
+ receiver={serviceReceiver(serviceRefkey)}
243
+ parameters={methodParameters(program, operation, paramsNames)}
244
+ returns={<Seq2Type element={element} />}
245
+ doc={allIteratorDoc(operation, element)}
246
+ >
247
+ {ay.code`
248
+ return paginate(params.Page, func(page, size int) ([]${element}, int, error) {
249
+ pageParams := params
250
+ pageParams.Page = &PageParams{Size: Int(size), Number: Int(page)}
251
+
252
+ resp, err := s.${goExportedName(operation.methodName)}(ctx, ${listArguments})
253
+ if err != nil {
254
+ return nil, 0, err
255
+ }
256
+
257
+ return resp.Data, resp.Meta.Page.Total, nil
258
+ })
259
+ `}
260
+ </go.FunctionDeclaration>
261
+ )
262
+ }
263
+
264
+ function CursorListAllMethod({
265
+ program,
266
+ operation,
267
+ serviceRefkey,
268
+ paramsNames,
269
+ }: {
270
+ program: Program
271
+ operation: GoOperation
272
+ serviceRefkey: Refkey
273
+ paramsNames: Map<GoOperation, string>
274
+ }) {
275
+ const element = pageElement(program, operation.response!)
276
+ const listArguments = [
277
+ ...operation.pathParams.map((parameter) => localName(parameter.name)),
278
+ ...(operation.body ? ['request'] : []),
279
+ 'pageParams',
280
+ ].join(', ')
281
+
282
+ return (
283
+ <go.FunctionDeclaration
284
+ name={`${goExportedName(operation.methodName)}All`}
285
+ receiver={serviceReceiver(serviceRefkey)}
286
+ parameters={methodParameters(program, operation, paramsNames)}
287
+ returns={<Seq2Type element={element} />}
288
+ doc={allIteratorDoc(operation, element)}
289
+ >
290
+ {ay.code`
291
+ return paginateCursor(params.Page, func(after, before *string, size int) ([]${element}, *string, *string, error) {
292
+ pageParams := params
293
+ pageParams.Page = &CursorPageParams{Size: Int(size), After: after, Before: before}
294
+
295
+ resp, err := s.${goExportedName(operation.methodName)}(ctx, ${listArguments})
296
+ if err != nil {
297
+ return nil, nil, nil, err
298
+ }
299
+
300
+ return resp.Data, String(resp.Meta.Page.Next.GetOrEmpty()), String(resp.Meta.Page.Previous.GetOrEmpty()), nil
301
+ })
302
+ `}
303
+ </go.FunctionDeclaration>
304
+ )
305
+ }
306
+
307
+ function StreamMethod({
308
+ program,
309
+ operation,
310
+ serviceRefkey,
311
+ paramsNames,
312
+ }: {
313
+ program: Program
314
+ operation: GoOperation
315
+ serviceRefkey: Refkey
316
+ paramsNames: Map<GoOperation, string>
317
+ }) {
318
+ const requestBody = operation.body
319
+ ? operation.bodyOptional
320
+ ? 'optionalBody(request)'
321
+ : 'request'
322
+ : 'nil'
323
+
324
+ return (
325
+ <go.FunctionDeclaration
326
+ name={`${goExportedName(operation.methodName)}Stream`}
327
+ receiver={serviceReceiver(serviceRefkey)}
328
+ parameters={methodParameters(program, operation, paramsNames)}
329
+ returns={[go.std.io.ReadCloser, 'error']}
330
+ >
331
+ {ay.code`
332
+ ${pathCode(operation)}
333
+
334
+ req, err := s.client.newRequestWithContentType(ctx, ${httpMethod(operation.verb)}, path, ${operation.queryParams.length > 0 ? 'params.values()' : 'nil'}, ${requestBody}, ${JSON.stringify(operation.requestContentType ?? 'application/json')}, ${JSON.stringify(operation.responseContentType ?? 'text/csv')})
335
+ if err != nil {
336
+ return nil, err
337
+ }
338
+
339
+ resp, err := s.client.doStream(req)
340
+ if err != nil {
341
+ return nil, err
342
+ }
343
+
344
+ return resp.Body, nil
345
+ `}
346
+ </go.FunctionDeclaration>
347
+ )
348
+ }
349
+
350
+ function listParameterDeclarations(
351
+ program: Program,
352
+ operations: GoOperation[],
353
+ paramsNames: Map<GoOperation, string>,
354
+ ) {
355
+ const emitted = new Set<string>()
356
+
357
+ return (
358
+ <ay.List joiner={'\n\n'}>
359
+ {operations.flatMap((operation) => {
360
+ if (operation.queryParams.length === 0) {
361
+ return []
362
+ }
363
+
364
+ // resolveListParamsNames guarantees operations sharing a params name
365
+ // also share a query shape, so deduping by name emits one identical
366
+ // struct for all of them.
367
+ const paramsName = paramsNames.get(operation)!
368
+ if (emitted.has(paramsName)) {
369
+ return []
370
+ }
371
+ emitted.add(paramsName)
372
+
373
+ const deepObjects = operation.queryParams.filter(
374
+ (parameter) => parameter.queryCodec?.kind === 'deepObject',
375
+ )
376
+ const deepObjectNames = new Map(
377
+ deepObjects.map((parameter) => [
378
+ parameter.name,
379
+ deepObjectName(paramsName, parameter),
380
+ ]),
381
+ )
382
+ const paramsRefkey = ay.refkey()
383
+
384
+ return [
385
+ <ay.List joiner={'\n\n'}>
386
+ {deepObjects.map((parameter) => {
387
+ const codec = parameter.queryCodec!
388
+ if (codec.kind !== 'deepObject') {
389
+ return undefined
390
+ }
391
+ return (
392
+ <GoStruct
393
+ name={deepObjectNames.get(parameter.name)!}
394
+ fields={[...codec.model.properties.values()].map((property) =>
395
+ queryFilterField(program, property),
396
+ )}
397
+ tags={false}
398
+ />
399
+ )
400
+ })}
401
+ <go.StructTypeDeclaration name={paramsName} refkey={paramsRefkey}>
402
+ <ay.List hardline>
403
+ {operation.queryParams.map((parameter) =>
404
+ queryParamField(program, parameter, deepObjectNames),
405
+ )}
406
+ </ay.List>
407
+ </go.StructTypeDeclaration>
408
+ <go.FunctionDeclaration
409
+ name="values"
410
+ receiver={<go.FunctionReceiver name="p" type={paramsRefkey} />}
411
+ returns={url.Values}
412
+ >
413
+ {queryValuesBody(program, operation.queryParams)}
414
+ </go.FunctionDeclaration>
415
+ </ay.List>,
416
+ ]
417
+ })}
418
+ </ay.List>
419
+ )
420
+ }
421
+
422
+ function queryParamField(
423
+ program: Program,
424
+ parameter: GoParameter,
425
+ deepObjectNames: Map<string, string>,
426
+ ) {
427
+ switch (parameter.queryCodec?.kind) {
428
+ case 'page':
429
+ return <go.StructMember name="Page" type="*PageParams" />
430
+ case 'cursorPage':
431
+ return <go.StructMember name="Page" type="*CursorPageParams" />
432
+ case 'sort':
433
+ return (
434
+ <go.StructMember name={goExportedName(parameter.name)} type="*Sort" />
435
+ )
436
+ case 'deepObject':
437
+ return (
438
+ <go.StructMember
439
+ name={goExportedName(parameter.name)}
440
+ type={`*${deepObjectNames.get(parameter.name)!}`}
441
+ />
442
+ )
443
+ default:
444
+ const mapped = goType(program, parameter.type)
445
+ return (
446
+ <go.StructMember
447
+ name={goExportedName(parameter.name)}
448
+ type={
449
+ parameter.property.optional && !mapped.nilable ? (
450
+ <go.Pointer>{mapped.type}</go.Pointer>
451
+ ) : (
452
+ mapped.type
453
+ )
454
+ }
455
+ />
456
+ )
457
+ }
458
+ }
459
+
460
+ function queryValuesBody(program: Program, parameters: GoParameter[]) {
461
+ const statements: ay.Children[] = [ay.code`q := ${url.Values}{}`]
462
+
463
+ for (const parameter of parameters) {
464
+ switch (parameter.queryCodec?.kind) {
465
+ case 'page':
466
+ statements.push(ay.code`addPageParams(q, p.Page)`)
467
+ break
468
+ case 'cursorPage':
469
+ statements.push(ay.code`addCursorPageParams(q, p.Page)`)
470
+ break
471
+ case 'sort':
472
+ statements.push(
473
+ ay.code`addSort(q, ${JSON.stringify(parameter.name)}, p.${goExportedName(parameter.name)})`,
474
+ )
475
+ break
476
+ case 'deepObject':
477
+ statements.push(deepObjectValues(program, parameter))
478
+ break
479
+ case 'array': {
480
+ const field = `p.${goExportedName(parameter.name)}`
481
+ const wireName = JSON.stringify(parameter.name)
482
+ if (parameter.type.kind !== 'Model') {
483
+ throw new Error(
484
+ `array query parameter ${parameter.name} is not a model`,
485
+ )
486
+ }
487
+ const element = parameter.type.indexer?.value
488
+ if (!element) {
489
+ throw new Error(
490
+ `array query parameter ${parameter.name} has no element type`,
491
+ )
492
+ }
493
+ const value = queryScalarValue(program, element, 'value')
494
+ if (parameter.queryCodec.explode) {
495
+ statements.push(ay.code`for _, value := range ${field} {
496
+ q.Add(${wireName}, ${value})
497
+ }`)
498
+ } else {
499
+ const values = `${localName(parameter.name)}Values`
500
+ statements.push(ay.code`if len(${field}) > 0 {
501
+ ${values} := make([]string, 0, len(${field}))
502
+ for _, value := range ${field} {
503
+ ${values} = append(${values}, ${value})
504
+ }
505
+ q.Set(${wireName}, ${strings.Join}(${values}, ","))
506
+ }`)
507
+ }
508
+ break
509
+ }
510
+ case 'scalar':
511
+ default: {
512
+ const field = `p.${goExportedName(parameter.name)}`
513
+ const wireName = JSON.stringify(parameter.name)
514
+ if (parameter.property.optional) {
515
+ const value = queryScalarValue(program, parameter.type, `*${field}`)
516
+ statements.push(ay.code`if ${field} != nil {
517
+ q.Set(${wireName}, ${value})
518
+ }`)
519
+ } else {
520
+ const value = queryScalarValue(program, parameter.type, field)
521
+ statements.push(ay.code`q.Set(${wireName}, ${value})`)
522
+ }
523
+ break
524
+ }
525
+ }
526
+ }
527
+
528
+ statements.push(ay.code`return q`)
529
+ return <ay.List joiner={'\n\n'}>{statements}</ay.List>
530
+ }
531
+
532
+ export function deepObjectName(
533
+ paramsName: string,
534
+ parameter: GoParameter,
535
+ ): string {
536
+ if (parameter.name === 'filter') {
537
+ return `${paramsName.replace(/ListParams$/, '').replace(/Params$/, '')}Filter`
538
+ }
539
+
540
+ return `${paramsName.replace(/Params$/, '')}${goExportedName(parameter.name)}`
541
+ }
542
+
543
+ function queryFilterField(program: Program, property: ModelProperty) {
544
+ const kind = queryFilterKind(program, property.type)
545
+ let type: ay.Children
546
+
547
+ switch (kind) {
548
+ case 'string':
549
+ type = <go.Pointer>StringFilter</go.Pointer>
550
+ break
551
+ case 'stringExact':
552
+ type = <go.Pointer>StringExactFilter</go.Pointer>
553
+ break
554
+ case 'dateTime':
555
+ type = <go.Pointer>DateTimeFilter</go.Pointer>
556
+ break
557
+ case 'numeric':
558
+ type = <go.Pointer>NumericFilter</go.Pointer>
559
+ break
560
+ case 'boolean':
561
+ type = <go.Pointer>BooleanFilter</go.Pointer>
562
+ break
563
+ case 'labels':
564
+ type = 'map[string]*StringFilter'
565
+ break
566
+ case 'scalar': {
567
+ const mapped = goType(program, property.type)
568
+ type = mapped.nilable ? (
569
+ mapped.type
570
+ ) : (
571
+ <go.Pointer>{mapped.type}</go.Pointer>
572
+ )
573
+ break
574
+ }
575
+ }
576
+
577
+ return {
578
+ name: goExportedName(property.name),
579
+ wireName: resolveEncodedName(program, property, 'application/json'),
580
+ type,
581
+ optional: false,
582
+ nilable: false,
583
+ doc: $(program).type.getDoc(property),
584
+ }
585
+ }
586
+
587
+ function deepObjectValues(
588
+ program: Program,
589
+ parameter: GoParameter,
590
+ ): ay.Children {
591
+ const codec = parameter.queryCodec
592
+ if (codec?.kind !== 'deepObject') {
593
+ throw new Error(`${parameter.name} is not a deep-object query parameter`)
594
+ }
595
+
596
+ const root = `p.${goExportedName(parameter.name)}`
597
+ const fields = [...codec.model.properties.values()].map((property) => {
598
+ const field = `${root}.${goExportedName(property.name)}`
599
+ const wireName = resolveEncodedName(program, property, 'application/json')
600
+ const prefix = JSON.stringify(`${parameter.name}[${wireName}]`)
601
+
602
+ switch (queryFilterKind(program, property.type)) {
603
+ case 'string':
604
+ return ay.code`addStringFilter(q, ${prefix}, ${field})`
605
+ case 'stringExact':
606
+ return ay.code`addStringExactFilter(q, ${prefix}, ${field})`
607
+ case 'dateTime':
608
+ return ay.code`addDateTimeFilter(q, ${prefix}, ${field})`
609
+ case 'numeric':
610
+ return ay.code`addNumericFilter(q, ${prefix}, ${field})`
611
+ case 'boolean':
612
+ return ay.code`addBooleanFilter(q, ${prefix}, ${field})`
613
+ case 'labels':
614
+ return ay.code`for key, filter := range ${field} {
615
+ addStringFilter(q, ${JSON.stringify(`${parameter.name}[${wireName}][`)}+key+"]", filter)
616
+ }`
617
+ case 'scalar': {
618
+ const value = queryScalarValue(program, property.type, `*${field}`)
619
+ return ay.code`if ${field} != nil {
620
+ q.Set(${prefix}, ${value})
621
+ }`
622
+ }
623
+ }
624
+ })
625
+
626
+ return ay.code`if ${root} != nil {
627
+ ${(<ay.List joiner={'\n'}>{fields}</ay.List>)}
628
+ }`
629
+ }
630
+
631
+ export function queryScalarValue(
632
+ program: Program,
633
+ type: Type,
634
+ expression: string,
635
+ ): ay.Children {
636
+ const mapped = goType(program, type).type
637
+ const convert = (target: string) =>
638
+ mapped === target ? expression : `${target}(${expression})`
639
+
640
+ switch (queryScalarKind(program, type)) {
641
+ case 'string':
642
+ return convert('string')
643
+ case 'boolean':
644
+ return ay.code`${strconv.FormatBool}(${convert('bool')})`
645
+ case 'integer':
646
+ return isUnsignedType(type)
647
+ ? ay.code`${strconv.FormatUint}(${convert('uint64')}, 10)`
648
+ : ay.code`${strconv.FormatInt}(${convert('int64')}, 10)`
649
+ case 'float':
650
+ return ay.code`${strconv.FormatFloat}(${convert('float64')}, 'g', -1, 64)`
651
+ case 'dateTime':
652
+ // A dereference like *p.From must be parenthesized before .Format so the
653
+ // selector does not bind tighter than the dereference.
654
+ return ay.code`${expression.startsWith('*') ? `(${expression})` : expression}.Format(${go.std.time.RFC3339Nano})`
655
+ }
656
+ }
657
+
658
+ function isUnsignedType(type: Type): boolean {
659
+ if (type.kind !== 'Scalar') {
660
+ return false
661
+ }
662
+ for (
663
+ let current: typeof type | undefined = type;
664
+ current;
665
+ current = current.baseScalar
666
+ ) {
667
+ if (/^uint/.test(current.name)) {
668
+ return true
669
+ }
670
+ }
671
+ return false
672
+ }
673
+
674
+ function methodParameters(
675
+ program: Program,
676
+ operation: GoOperation,
677
+ paramsNames: Map<GoOperation, string>,
678
+ ): { name: string; type: ay.Children }[] {
679
+ const parameters: { name: string; type: ay.Children }[] = [
680
+ { name: 'ctx', type: context.Context },
681
+ ...operation.pathParams.map((parameter) => ({
682
+ name: localName(parameter.name),
683
+ type: 'string',
684
+ })),
685
+ ]
686
+ if (operation.body) {
687
+ const mapped = goType(program, operation.body, { mode: 'input' })
688
+ parameters.push({
689
+ name: 'request',
690
+ type:
691
+ operation.bodyOptional && !mapped.nilable ? (
692
+ <go.Pointer>{mapped.type}</go.Pointer>
693
+ ) : (
694
+ mapped.type
695
+ ),
696
+ })
697
+ }
698
+ if (operation.queryParams.length > 0) {
699
+ const paramsName = paramsNames.get(operation)
700
+ if (!paramsName) {
701
+ throw new Error(
702
+ `typespec-go: no params struct name resolved for ${operation.operation.name}`,
703
+ )
704
+ }
705
+ parameters.push({
706
+ name: 'params',
707
+ type: paramsName,
708
+ })
709
+ }
710
+
711
+ return parameters
712
+ }
713
+
714
+ function pathCode(operation: GoOperation): ay.Children {
715
+ const errPrefix =
716
+ operation.response || isTextResponse(operation) ? 'nil, ' : ''
717
+ const guards: ay.Children[] = operation.pathParams.map(
718
+ (parameter) =>
719
+ ay.code`if ${localName(parameter.name)} == "" {
720
+ return ${errPrefix}${go.std.fmt.Errorf}("openmeter: %s must not be empty: %w", ${JSON.stringify(localName(parameter.name))}, ErrEmptyID)
721
+ }`,
722
+ )
723
+ const substitutions: ay.Children[] = operation.pathParams.map(
724
+ (parameter) =>
725
+ ay.code`path = replacePathParam(path, ${JSON.stringify(parameter.name)}, ${localName(parameter.name)})`,
726
+ )
727
+
728
+ return (
729
+ <ay.List joiner={'\n\n'}>
730
+ {[
731
+ ...guards,
732
+ ay.code`path := ${JSON.stringify(operation.path)}`,
733
+ ...substitutions,
734
+ ]}
735
+ </ay.List>
736
+ )
737
+ }
738
+
739
+ function serviceReceiver(serviceRefkey: Refkey) {
740
+ return (
741
+ <go.FunctionReceiver
742
+ name="s"
743
+ type={
744
+ <go.Pointer>
745
+ <go.Reference refkey={serviceRefkey} />
746
+ </go.Pointer>
747
+ }
748
+ />
749
+ )
750
+ }
751
+
752
+ function Seq2Type({ element }: { element: string }) {
753
+ return (
754
+ <>
755
+ {iter.Seq2}[{element}, error]
756
+ </>
757
+ )
758
+ }
759
+
760
+ function httpMethod(verb: string): ay.Children {
761
+ switch (verb.toUpperCase()) {
762
+ case 'GET':
763
+ return go.std.net.http.MethodGet
764
+ case 'POST':
765
+ return go.std.net.http.MethodPost
766
+ case 'PUT':
767
+ return go.std.net.http.MethodPut
768
+ case 'PATCH':
769
+ return go.std.net.http.MethodPatch
770
+ case 'DELETE':
771
+ return go.std.net.http.MethodDelete
772
+ default:
773
+ return JSON.stringify(verb.toUpperCase())
774
+ }
775
+ }
776
+
777
+ function isTextResponse(operation: GoOperation): boolean {
778
+ return operation.responseContentType?.startsWith('text/') ?? false
779
+ }
780
+
781
+ /**
782
+ * Resolves the params struct name for every operation that has query
783
+ * parameters.
784
+ *
785
+ * Params structs are preferably named after the page element
786
+ * (CustomerListParams). Distinct operations can legitimately page over the
787
+ * same element with different query shapes (list_prices supports sort,
788
+ * list_overrides does not); naming both after the element would merge two
789
+ * different shapes into whichever struct happens to be discovered first.
790
+ * Every operation in such a conflicted group therefore gets its
791
+ * operation-derived name instead, keeping the outcome independent of
792
+ * operation iteration order.
793
+ */
794
+ export function resolveListParamsNames(
795
+ program: Program,
796
+ operations: GoOperation[],
797
+ ): Map<GoOperation, string> {
798
+ const groups = new Map<string, GoOperation[]>()
799
+ for (const operation of operations) {
800
+ if (operation.queryParams.length === 0) {
801
+ continue
802
+ }
803
+ const name = listParamsName(program, operation)
804
+ const group = groups.get(name)
805
+ if (group) {
806
+ group.push(operation)
807
+ } else {
808
+ groups.set(name, [operation])
809
+ }
810
+ }
811
+
812
+ const resolved = new Map<GoOperation, string>()
813
+ const shapes = new Map<string, string>()
814
+ for (const [name, group] of groups) {
815
+ const signatures = new Set(
816
+ group.map((operation) => querySignature(program, operation.queryParams)),
817
+ )
818
+ for (const operation of group) {
819
+ const finalName =
820
+ signatures.size > 1 ? operationParamsName(program, operation) : name
821
+ const signature = querySignature(program, operation.queryParams)
822
+ const existing = shapes.get(finalName)
823
+ if (existing !== undefined && existing !== signature) {
824
+ throw new Error(
825
+ `typespec-go: params struct ${finalName} would be emitted with two different query shapes; add a distinct @friendlyName or @operationId to one of the operations`,
826
+ )
827
+ }
828
+ shapes.set(finalName, signature)
829
+ resolved.set(operation, finalName)
830
+ }
831
+ }
832
+
833
+ return resolved
834
+ }
835
+
836
+ /**
837
+ * Canonical signature of an operation's query parameter list mirroring
838
+ * exactly what the generated params struct and its values() body depend on:
839
+ * parameter order, wire names, codec kinds, optionality, and rendered Go
840
+ * field types. Two operations may share a params struct only when their
841
+ * signatures are equal.
842
+ */
843
+ function querySignature(program: Program, parameters: GoParameter[]): string {
844
+ return parameters
845
+ .map((parameter) => {
846
+ const codec = parameter.queryCodec
847
+ switch (codec?.kind) {
848
+ case 'page':
849
+ return 'page'
850
+ case 'cursorPage':
851
+ return 'cursorPage'
852
+ case 'sort':
853
+ return `sort:${parameter.name}`
854
+ case 'deepObject':
855
+ return `deepObject:${parameter.name}:{${[
856
+ ...codec.model.properties.values(),
857
+ ]
858
+ .map(
859
+ (property) =>
860
+ `${property.name}:${resolveEncodedName(program, property, 'application/json')}:${filterFieldSignature(program, property.type)}`,
861
+ )
862
+ .join(',')}}`
863
+ case 'array': {
864
+ if (
865
+ parameter.type.kind !== 'Model' ||
866
+ !parameter.type.indexer?.value
867
+ ) {
868
+ throw new Error(
869
+ `array query parameter ${parameter.name} has no element type`,
870
+ )
871
+ }
872
+ return `array:${parameter.name}:${codec.explode}:${scalarSignature(program, parameter.type.indexer.value)}`
873
+ }
874
+ default:
875
+ return `scalar:${parameter.name}:${parameter.property.optional}:${scalarSignature(program, parameter.type)}`
876
+ }
877
+ })
878
+ .join(';')
879
+ }
880
+
881
+ function filterFieldSignature(program: Program, type: Type): string {
882
+ const kind = queryFilterKind(program, type)
883
+ return kind === 'scalar' ? `scalar:${scalarSignature(program, type)}` : kind
884
+ }
885
+
886
+ function scalarSignature(program: Program, type: Type): string {
887
+ const mapped = goType(program, type).type
888
+ return typeof mapped === 'string' ? mapped : queryScalarKind(program, type)
889
+ }
890
+
891
+ function operationParamsName(program: Program, operation: GoOperation): string {
892
+ return `${goExportedName(operationBaseName(program, operation.operation))}Params`
893
+ }
894
+
895
+ function listParamsName(program: Program, operation: GoOperation): string {
896
+ if (operation.pagination && operation.response) {
897
+ try {
898
+ return `${pageElement(program, operation.response)}ListParams`
899
+ } catch {
900
+ // A cursor-bearing operation can return a non-standard envelope. Keep a
901
+ // collision-free operation name rather than guessing an element type.
902
+ }
903
+ }
904
+
905
+ return operationParamsName(program, operation)
906
+ }
907
+
908
+ /**
909
+ * All-iterators surface only the page elements, so they are emitted only for
910
+ * the canonical {data, meta} page envelope. Any extra response field (for
911
+ * example GovernanceQueryResponse.errors, which reports partial failures)
912
+ * would be silently dropped from the iteration; such operations only get the
913
+ * plain method that returns the full envelope.
914
+ */
915
+ export function isPlainPageEnvelope(
916
+ program: Program,
917
+ response: Type | undefined,
918
+ ): boolean {
919
+ if (response?.kind !== 'Model') {
920
+ return false
921
+ }
922
+
923
+ const properties = [...walkPropertiesInherited(response)].filter(
924
+ (property) =>
925
+ !isStatusCode(program, property) && !isHeader(program, property),
926
+ )
927
+
928
+ return (
929
+ properties.length === 2 &&
930
+ properties.some(
931
+ (property) =>
932
+ property.name === 'data' &&
933
+ property.type.kind === 'Model' &&
934
+ $(program).array.is(property.type),
935
+ ) &&
936
+ properties.some((property) => property.name === 'meta')
937
+ )
938
+ }
939
+
940
+ function allIteratorDoc(operation: GoOperation, element: string): string {
941
+ const listName = goExportedName(operation.methodName)
942
+ return `${listName}All returns an iterator over all ${element} results, fetching pages of ${listName} transparently. Iteration stops at the first error, which is yielded as the second value.`
943
+ }
944
+
945
+ function pageElement(program: Program, response: Type): string {
946
+ if (response.kind !== 'Model') {
947
+ throw new Error('paginated response must be a model')
948
+ }
949
+ const data = response.properties.get('data')
950
+ if (!data || data.type.kind !== 'Model' || !$(program).array.is(data.type)) {
951
+ throw new Error(
952
+ `${typeName(program, response)} must contain an array data property`,
953
+ )
954
+ }
955
+ const element = data.type.indexer?.value
956
+ if (!element) {
957
+ throw new Error(`${typeName(program, response)} data array has no element`)
958
+ }
959
+
960
+ return readReferenceName(program, element)
961
+ }
962
+
963
+ function readReferenceName(program: Program, type: Type): string {
964
+ const mapped = goType(program, type).type
965
+ return typeof mapped === 'string' ? mapped : typeName(program, type)
966
+ }
967
+
968
+ // Locals every generated method body may declare (including the receiver and
969
+ // the paginate callback arguments that capture path parameters); a path
970
+ // parameter with one of these names would shadow them and emit non-compiling
971
+ // or subtly wrong Go.
972
+ const reservedMethodLocals = new Set([
973
+ 'after',
974
+ 'before',
975
+ 'ctx',
976
+ 'err',
977
+ 'out',
978
+ 'page',
979
+ 'pageParams',
980
+ 'params',
981
+ 'path',
982
+ 'q',
983
+ 'req',
984
+ 'request',
985
+ 'resp',
986
+ 's',
987
+ 'size',
988
+ ])
989
+
990
+ export function localName(name: string): string {
991
+ const exported = goExportedName(name)
992
+ const acronym = exported.match(/^([A-Z]{2,})([A-Z][a-z].*)$/)
993
+ const local = /^[A-Z0-9]+$/.test(exported)
994
+ ? exported.toLowerCase()
995
+ : acronym?.[1] && acronym[2]
996
+ ? acronym[1].toLowerCase() + acronym[2]
997
+ : exported.charAt(0).toLowerCase() + exported.slice(1)
998
+
999
+ return reservedMethodLocals.has(local) ? `${local}Param` : local
1000
+ }
api/spec/packages/typespec-go/src/components/GoStruct.tsx ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as ay from '@alloy-js/core'
2
+ import * as go from '@alloy-js/go'
3
+ import type { Refkey } from '@alloy-js/core'
4
+ import type { GoField } from '../go-types.js'
5
+
6
+ export interface GoStructProps {
7
+ name: string
8
+ fields: GoField[]
9
+ refkey?: Refkey
10
+ doc?: string
11
+ tags?: boolean
12
+ }
13
+
14
+ export function GoStruct({
15
+ name,
16
+ fields,
17
+ refkey,
18
+ doc,
19
+ tags = true,
20
+ }: GoStructProps) {
21
+ return (
22
+ <go.StructTypeDeclaration name={name} refkey={refkey} doc={doc}>
23
+ <ay.List hardline>
24
+ {fields.map((field) => (
25
+ <go.StructMember
26
+ name={field.name}
27
+ type={field.type}
28
+ doc={field.doc}
29
+ tag={
30
+ tags
31
+ ? {
32
+ json: `${field.wireName}${field.optional ? ',omitempty' : ''}`,
33
+ }
34
+ : undefined
35
+ }
36
+ />
37
+ ))}
38
+ </ay.List>
39
+ </go.StructTypeDeclaration>
40
+ )
41
+ }
api/spec/packages/typespec-go/src/components/GoTypeSpecEnum.tsx ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as ay from '@alloy-js/core'
2
+ import * as go from '@alloy-js/go'
3
+ import type { Enum } from '@typespec/compiler'
4
+ import { goExportedName } from '../go-types.js'
5
+
6
+ export function GoTypeSpecEnum({
7
+ enumType,
8
+ name,
9
+ doc,
10
+ }: {
11
+ enumType: Enum
12
+ name: string
13
+ doc?: string
14
+ }) {
15
+ const members = [...enumType.members.values()].map((member) => ({
16
+ name: `${name}${goExportedName(member.name)}`,
17
+ value: member.value ?? member.name,
18
+ }))
19
+ const underlying = members.some((member) => typeof member.value === 'number')
20
+ ? 'int'
21
+ : 'string'
22
+
23
+ return (
24
+ <>
25
+ <go.TypeDeclaration name={name} doc={doc}>
26
+ {underlying}
27
+ </go.TypeDeclaration>
28
+ {'\n\n'}
29
+ <go.VariableDeclarationGroup const>
30
+ <ay.List hardline>
31
+ {members.map((member) => (
32
+ <go.VariableDeclaration name={member.name} type={name}>
33
+ {JSON.stringify(member.value)}
34
+ </go.VariableDeclaration>
35
+ ))}
36
+ </ay.List>
37
+ </go.VariableDeclarationGroup>
38
+ {'\n\n'}
39
+ <go.FunctionDeclaration
40
+ name="Valid"
41
+ receiver={<go.FunctionReceiver name="value" type={name} />}
42
+ returns="bool"
43
+ >
44
+ {ay.code`
45
+ switch value {
46
+ case ${members.map((member) => member.name).join(', ')}:
47
+ return true
48
+ default:
49
+ return false
50
+ }
51
+ `}
52
+ </go.FunctionDeclaration>
53
+ </>
54
+ )
55
+ }
api/spec/packages/typespec-go/src/components/GoUnion.tsx ADDED
@@ -0,0 +1,389 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as ay from '@alloy-js/core'
2
+ import * as go from '@alloy-js/go'
3
+ import type {
4
+ Model,
5
+ ModelProperty,
6
+ Program,
7
+ Type,
8
+ Union,
9
+ UnionVariant,
10
+ } from '@typespec/compiler'
11
+ import { resolveEncodedName } from '@typespec/compiler'
12
+ import { $ } from '@typespec/compiler/typekit'
13
+ import { goExportedName, goType } from '../go-types.js'
14
+ import { json } from '../stdlib.js'
15
+
16
+ export interface GoUnionProps {
17
+ program: Program
18
+ union: Union
19
+ name: string
20
+ mode?: 'input' | 'output'
21
+ doc?: string
22
+ }
23
+
24
+ /**
25
+ * Emits a JSON-preserving tagged union with named, typed accessors for every
26
+ * model variant. The raw payload is retained so unknown discriminator values
27
+ * remain forward compatible and round-trip unchanged.
28
+ */
29
+ export function GoUnion({ program, union, name, mode, doc }: GoUnionProps) {
30
+ const modelVariants = [...union.variants.values()].flatMap((variant) =>
31
+ variant.type.kind === 'Model' ? [variant.type] : [],
32
+ )
33
+ const discriminator = discriminatorProperty(program, union, modelVariants)
34
+ if (!discriminator && modelVariants.length > 1) {
35
+ throw new Error(
36
+ `typespec-go: union ${name} has multiple object variants but no discriminator; add @discriminated so Go accessors can select variants safely`,
37
+ )
38
+ }
39
+ // The discriminated path assumes every selectable variant is a model: the
40
+ // From* constructors envelope the payload under the discriminator property
41
+ // and the As* accessors match on it. A scalar or enum variant has neither,
42
+ // so it would emit constructors/accessors that cannot round-trip.
43
+ if (discriminator) {
44
+ const nonModelVariants = [...union.variants.values()].filter(
45
+ (variant) =>
46
+ variant.type.kind !== 'Model' && variant.type.kind !== 'Intrinsic',
47
+ )
48
+ if (nonModelVariants.length > 0) {
49
+ throw new Error(
50
+ `typespec-go: discriminated union ${name} mixes model and non-model variants (${nonModelVariants
51
+ .map((variant) => variant.type.kind)
52
+ .join(', ')}); only model variants can carry the discriminator`,
53
+ )
54
+ }
55
+ }
56
+ const variants = [...union.variants.values()].flatMap((variant) => {
57
+ if (variant.type.kind === 'Intrinsic') {
58
+ return []
59
+ }
60
+
61
+ const mapped = goType(program, variant.type, { mode }).type
62
+ return [
63
+ {
64
+ variant,
65
+ type: variant.type,
66
+ name:
67
+ variant.type.kind === 'Model' && typeof mapped === 'string'
68
+ ? mapped
69
+ : variantAccessorName(program, name, variant),
70
+ goType: mapped,
71
+ discriminatorValue:
72
+ variant.type.kind === 'Model'
73
+ ? discriminatorLiteral(variant.type, discriminator)
74
+ : undefined,
75
+ },
76
+ ]
77
+ })
78
+ const discriminatorField = discriminator
79
+ ? goExportedName(discriminator.wireName.replace(/^\$/, ''))
80
+ : 'Type'
81
+
82
+ const contractDoc = [
83
+ ...(doc ? [doc, ''] : []),
84
+ `${name} is a JSON-preserving tagged union: its zero value marshals as JSON null, and values must be built with the ${name}From* constructors.`,
85
+ ...(discriminator
86
+ ? [
87
+ `The exported ${discriminatorField} field is decode-side metadata; MarshalJSON round-trips the original payload and ignores writes to it.`,
88
+ ]
89
+ : []),
90
+ ].join('\n')
91
+
92
+ return (
93
+ <ay.List joiner={'\n\n'}>
94
+ <go.StructTypeDeclaration name={name} doc={contractDoc}>
95
+ <ay.List hardline>
96
+ {discriminator ? (
97
+ <go.StructMember
98
+ name={discriminatorField}
99
+ type="string"
100
+ tag={{ json: discriminator.wireName }}
101
+ />
102
+ ) : undefined}
103
+ <go.StructMember name="raw" type={json.RawMessage} />
104
+ </ay.List>
105
+ </go.StructTypeDeclaration>
106
+ <go.FunctionDeclaration
107
+ name="UnmarshalJSON"
108
+ receiver={
109
+ <go.FunctionReceiver
110
+ name="u"
111
+ type={<go.Pointer>{name}</go.Pointer>}
112
+ />
113
+ }
114
+ parameters={[{ name: 'data', type: '[]byte' }]}
115
+ returns="error"
116
+ >
117
+ {discriminator
118
+ ? ay.code`
119
+ u.raw = append([]byte(nil), data...)
120
+ if string(data) == "null" {
121
+ u.${discriminatorField} = ""
122
+ return nil
123
+ }
124
+
125
+ var envelope struct {
126
+ Value string ${`\`json:${JSON.stringify(discriminator.wireName)}\``}
127
+ }
128
+ if err := ${json.Unmarshal}(data, &envelope); err != nil {
129
+ return err
130
+ }
131
+ u.${discriminatorField} = envelope.Value
132
+ return nil
133
+ `
134
+ : ay.code`
135
+ u.raw = append([]byte(nil), data...)
136
+ return nil
137
+ `}
138
+ </go.FunctionDeclaration>
139
+ <go.FunctionDeclaration
140
+ name="MarshalJSON"
141
+ receiver={<go.FunctionReceiver name="u" type={name} />}
142
+ returns={['[]byte', 'error']}
143
+ >
144
+ {ay.code`
145
+ if len(u.raw) == 0 {
146
+ return []byte("null"), nil
147
+ }
148
+ return append([]byte(nil), u.raw...), nil
149
+ `}
150
+ </go.FunctionDeclaration>
151
+ <ay.List>
152
+ {variants.flatMap((item) => [
153
+ <go.FunctionDeclaration
154
+ name={`As${item.name}`}
155
+ receiver={<go.FunctionReceiver name="u" type={name} />}
156
+ returns={[<go.Pointer>{item.goType}</go.Pointer>, 'error']}
157
+ >
158
+ {accessorBody({
159
+ unionName: name,
160
+ variantName: item.name,
161
+ variantType: item.type,
162
+ goType: item.goType,
163
+ discriminator,
164
+ discriminatorField,
165
+ discriminatorValue: item.discriminatorValue,
166
+ })}
167
+ </go.FunctionDeclaration>,
168
+ '\n\n',
169
+ <go.FunctionDeclaration
170
+ name={`${name}From${item.name}`}
171
+ parameters={[{ name: 'value', type: item.goType }]}
172
+ returns={[name, 'error']}
173
+ >
174
+ {discriminator && item.discriminatorValue !== undefined
175
+ ? ay.code`
176
+ value.${discriminatorField} = ${JSON.stringify(item.discriminatorValue)}
177
+ raw, err := ${json.Marshal}(value)
178
+ if err != nil {
179
+ return ${name}{}, err
180
+ }
181
+ var result ${name}
182
+ if err := result.UnmarshalJSON(raw); err != nil {
183
+ return ${name}{}, err
184
+ }
185
+ return result, nil
186
+ `
187
+ : ay.code`
188
+ raw, err := ${json.Marshal}(value)
189
+ if err != nil {
190
+ return ${name}{}, err
191
+ }
192
+ var result ${name}
193
+ if err := result.UnmarshalJSON(raw); err != nil {
194
+ return ${name}{}, err
195
+ }
196
+ return result, nil
197
+ `}
198
+ </go.FunctionDeclaration>,
199
+ '\n\n',
200
+ ])}
201
+ </ay.List>
202
+ </ay.List>
203
+ )
204
+ }
205
+
206
+ function accessorBody({
207
+ unionName,
208
+ variantName,
209
+ variantType,
210
+ goType: goTypeName,
211
+ discriminator,
212
+ discriminatorField,
213
+ discriminatorValue,
214
+ }: {
215
+ unionName: string
216
+ variantName: string
217
+ variantType: Type
218
+ goType: ay.Children
219
+ discriminator: { name: string; wireName: string } | undefined
220
+ discriminatorField: string
221
+ discriminatorValue: string | undefined
222
+ }): ay.Children {
223
+ const discriminatorGuard =
224
+ discriminator && discriminatorValue !== undefined
225
+ ? ay.code`
226
+ if u.${discriminatorField} != ${JSON.stringify(discriminatorValue)} {
227
+ return nil, ${go.std.fmt.Errorf}("${unionName}: expected ${discriminator.wireName} %q, got %q", ${JSON.stringify(discriminatorValue)}, u.${discriminatorField})
228
+ }
229
+ `
230
+ : undefined
231
+ const scalarGuard = variantValidation(unionName, variantName, variantType)
232
+
233
+ if (discriminatorGuard && scalarGuard) {
234
+ return ay.code`
235
+ ${discriminatorGuard}
236
+ var value ${goTypeName}
237
+ if err := ${json.Unmarshal}(u.raw, &value); err != nil {
238
+ return nil, err
239
+ }
240
+ ${scalarGuard}
241
+ return &value, nil
242
+ `
243
+ }
244
+
245
+ if (discriminatorGuard) {
246
+ return ay.code`
247
+ ${discriminatorGuard}
248
+ var value ${goTypeName}
249
+ if err := ${json.Unmarshal}(u.raw, &value); err != nil {
250
+ return nil, err
251
+ }
252
+ return &value, nil
253
+ `
254
+ }
255
+
256
+ if (scalarGuard) {
257
+ return ay.code`
258
+ var value ${goTypeName}
259
+ if err := ${json.Unmarshal}(u.raw, &value); err != nil {
260
+ return nil, err
261
+ }
262
+ ${scalarGuard}
263
+ return &value, nil
264
+ `
265
+ }
266
+
267
+ return ay.code`
268
+ var value ${goTypeName}
269
+ if err := ${json.Unmarshal}(u.raw, &value); err != nil {
270
+ return nil, err
271
+ }
272
+ return &value, nil
273
+ `
274
+ }
275
+
276
+ export function variantAccessorName(
277
+ program: Program,
278
+ unionName: string,
279
+ variant: UnionVariant,
280
+ ): string {
281
+ const variantName =
282
+ typeof variant.name === 'symbol'
283
+ ? `Variant${goExportedName(String(variant.name.description ?? ''))}`
284
+ : goExportedName(variant.name)
285
+ return variantName || `${unionName}Variant`
286
+ }
287
+
288
+ export function discriminatorProperty(
289
+ program: Program,
290
+ union: Union,
291
+ variants: Model[],
292
+ ): { name: string; wireName: string } | undefined {
293
+ if (variants.length === 0) {
294
+ return undefined
295
+ }
296
+
297
+ const discriminated = $(program).union.getDiscriminatedUnion(union)
298
+ if (discriminated) {
299
+ if (discriminated.options.envelope !== 'none') {
300
+ throw new Error(
301
+ `typespec-go: union ${union.name ?? '<anonymous union>'} uses unsupported discriminated union envelope ${discriminated.options.envelope}`,
302
+ )
303
+ }
304
+ const name = discriminated.options.discriminatorPropertyName
305
+ const property = variants[0]?.properties.get(name)
306
+ return {
307
+ name,
308
+ wireName: property
309
+ ? resolveEncodedName(
310
+ program,
311
+ property as ModelProperty & { name: string },
312
+ 'application/json',
313
+ )
314
+ : name,
315
+ }
316
+ }
317
+
318
+ for (const candidate of ['type', '$type']) {
319
+ if (
320
+ variants.length > 0 &&
321
+ variants.every((model) => model.properties.has(candidate))
322
+ ) {
323
+ const property = variants[0]!.properties.get(candidate)!
324
+ return {
325
+ name: candidate,
326
+ wireName: resolveEncodedName(program, property, 'application/json'),
327
+ }
328
+ }
329
+ }
330
+
331
+ return undefined
332
+ }
333
+
334
+ export function discriminatorLiteral(
335
+ model: Model,
336
+ discriminator: { name: string; wireName: string } | undefined,
337
+ ): string | undefined {
338
+ if (!discriminator) {
339
+ return undefined
340
+ }
341
+
342
+ const property = model.properties.get(discriminator.name)
343
+ if (!property) {
344
+ return undefined
345
+ }
346
+
347
+ switch (property.type.kind) {
348
+ case 'String':
349
+ return property.type.value
350
+ case 'EnumMember':
351
+ return String(property.type.value ?? property.type.name)
352
+ default:
353
+ return undefined
354
+ }
355
+ }
356
+
357
+ function variantValidation(
358
+ unionName: string,
359
+ variantName: string,
360
+ type: Type,
361
+ ): ay.Children {
362
+ switch (type.kind) {
363
+ case 'Enum':
364
+ return ay.code`
365
+ if !value.Valid() {
366
+ return nil, ${go.std.fmt.Errorf}("${unionName}: value %q is not ${variantName}", value)
367
+ }
368
+ `
369
+ case 'EnumMember':
370
+ return ay.code`
371
+ if value != ${JSON.stringify(type.value ?? type.name)} {
372
+ return nil, ${go.std.fmt.Errorf}("${unionName}: value %q is not ${variantName}", value)
373
+ }
374
+ `
375
+ case 'String':
376
+ case 'Number':
377
+ case 'Boolean':
378
+ if ('value' in type && type.value !== undefined) {
379
+ return ay.code`
380
+ if value != ${JSON.stringify(type.value)} {
381
+ return nil, ${go.std.fmt.Errorf}("${unionName}: value %q is not ${variantName}", value)
382
+ }
383
+ `
384
+ }
385
+ return undefined
386
+ default:
387
+ return undefined
388
+ }
389
+ }
api/spec/packages/typespec-go/src/emitter.tsx ADDED
@@ -0,0 +1,602 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as go from '@alloy-js/go'
2
+ import * as ay from '@alloy-js/core'
3
+ import { execFile } from 'node:child_process'
4
+ import { promisify } from 'node:util'
5
+ import { type EmitContext, emitFile, resolvePath } from '@typespec/compiler'
6
+ import { $ } from '@typespec/compiler/typekit'
7
+ import { Output, writeOutput } from '@typespec/emitter-framework'
8
+ import { GoClient } from './components/GoClient.js'
9
+ import { GoModels } from './components/GoModels.js'
10
+ import { GoResource } from './components/GoResource.js'
11
+ import {
12
+ configureGoProjections,
13
+ configureGoTypeNames,
14
+ optionalTypeName,
15
+ queryScalarKind,
16
+ resolveGoTypeNames,
17
+ setSyntheticTypeNames,
18
+ type GoDeclarationPlan,
19
+ } from './go-types.js'
20
+ import { groupOperations, operationNestPath } from './grouping.js'
21
+ import type { GoEmitterOptions } from './lib.js'
22
+ import {
23
+ computeDivergentTypes,
24
+ computeReachability,
25
+ computeStructuralAliases,
26
+ planDeclarations,
27
+ promoteAnonymousModels,
28
+ } from './projections.js'
29
+ import {
30
+ collectHttpOperations,
31
+ describeOperations,
32
+ jsonBodyOverrides,
33
+ } from './operations.js'
34
+ import { readmeFile } from './readme.js'
35
+ import { RUNTIME_TEMPLATES } from './runtime-templates.js'
36
+ import {
37
+ conflictsWithReservedGoSymbol,
38
+ isRuntimeBackedTypeName,
39
+ } from './runtime-symbols.js'
40
+
41
+ const execFileAsync = promisify(execFile)
42
+ const generatedHeader =
43
+ '// Code generated by @openmeter/typespec-go. DO NOT EDIT.'
44
+
45
+ /**
46
+ * Emits the OpenMeter Go SDK from the AIP TypeSpec program.
47
+ *
48
+ * The static runtime is emitted from TypeScript templates
49
+ * (RUNTIME_TEMPLATES). Models, the root client, and every resource service are
50
+ * generated from the walked program through one metadata-driven path. The
51
+ * optional include-resources setting only narrows that generic path; by default
52
+ * every discovered operation group is emitted.
53
+ */
54
+ export async function $onEmit(context: EmitContext<GoEmitterOptions>) {
55
+ const { program, emitterOutputDir } = context
56
+ const pkg = context.options['package-name']
57
+ const modulePath = context.options['module-path']
58
+ const sdkVersion = context.options['sdk-version'] ?? '0.0.0-dev'
59
+ const goVersion = context.options['go-version'] ?? '1.23'
60
+ if (!modulePath) {
61
+ throw new Error(
62
+ "typespec-go: the module-path option is required; set it to the SDK's published Go module path (e.g. github.com/openmeterio/openmeter/api/v3/client) in tspconfig.yaml",
63
+ )
64
+ }
65
+ if (!pkg) {
66
+ throw new Error(
67
+ 'typespec-go: the package-name option is required; set it to the generated Go package name (e.g. openmeter) in tspconfig.yaml',
68
+ )
69
+ }
70
+ configureGoTypeNames(program, context.options['strip-name-prefixes'] ?? [])
71
+
72
+ await cleanOutputDirectory(program.host, emitterOutputDir)
73
+
74
+ const operations = collectHttpOperations(
75
+ program,
76
+ context.options['include-services'],
77
+ )
78
+ const groups = groupOperations(operations)
79
+ const includeResources = new Set(
80
+ context.options['include-resources'] ?? groups.keys(),
81
+ )
82
+ for (const resource of includeResources) {
83
+ if (!groups.has(resource)) {
84
+ throw new Error(
85
+ `unknown include-resources entry ${resource}; available resources: ${[...groups.keys()].join(', ')}`,
86
+ )
87
+ }
88
+ }
89
+ const includedGroups = [...groups].filter(([resource]) =>
90
+ includeResources.has(resource),
91
+ )
92
+ const bodyOverrides = jsonBodyOverrides(program)
93
+ const reachability = computeReachability(
94
+ program,
95
+ includedGroups,
96
+ bodyOverrides,
97
+ )
98
+ const modelTypes = new Set(
99
+ [...reachability.byResource.values()].flatMap((types) => [...types]),
100
+ )
101
+ resolveGoTypeNames(program, modelTypes)
102
+ setSyntheticTypeNames(program, promoteAnonymousModels(program, modelTypes))
103
+ const typeOwners = new Map<import('@typespec/compiler').Type, Set<string>>()
104
+ for (const [resource, types] of reachability.byResource) {
105
+ for (const type of types) {
106
+ const owners = typeOwners.get(type)
107
+ if (owners) {
108
+ owners.add(resource)
109
+ } else {
110
+ typeOwners.set(type, new Set([resource]))
111
+ }
112
+ }
113
+ }
114
+ const divergentTypes = computeDivergentTypes(
115
+ program,
116
+ reachability.readReachable,
117
+ reachability.inputReachable,
118
+ )
119
+ const declarationPlan = planDeclarations(
120
+ program,
121
+ modelTypes,
122
+ reachability.readReachable,
123
+ reachability.inputReachable,
124
+ divergentTypes,
125
+ )
126
+ const structuralAliases = computeStructuralAliases(
127
+ program,
128
+ declarationPlan,
129
+ reachability.readReachable,
130
+ divergentTypes,
131
+ )
132
+ configureGoProjections(program, {
133
+ readReachable: reachability.readReachable,
134
+ inputReachable: reachability.inputReachable,
135
+ divergent: divergentTypes,
136
+ aliases: structuralAliases,
137
+ declarations: declarationPlan,
138
+ })
139
+ validateUniqueTypeNames(program, modelTypes)
140
+ const modelGroups = groupTypesByOwner(
141
+ program,
142
+ modelTypes,
143
+ typeOwners,
144
+ declarationPlan,
145
+ )
146
+
147
+ // Runtime helpers stay verbatim. All spec-derived models, services, and
148
+ // client wiring are emitted below.
149
+ for (const [path, content] of Object.entries(RUNTIME_TEMPLATES)) {
150
+ await emitFile(program, {
151
+ path: resolvePath(emitterOutputDir, path),
152
+ content: prepareRuntimeTemplate(
153
+ path,
154
+ content,
155
+ pkg,
156
+ modulePath,
157
+ sdkVersion,
158
+ goVersion,
159
+ ),
160
+ })
161
+ }
162
+
163
+ const clientRefkey = ay.refkey()
164
+ const resources = includedGroups.flatMap(([root, resourceOperations]) => {
165
+ const operationsByPath = new Map<string, typeof resourceOperations>()
166
+ operationsByPath.set('', [])
167
+ for (const operation of resourceOperations) {
168
+ const nestPath = operationNestPath(operation, root)
169
+ for (let depth = 1; depth <= nestPath.length; depth++) {
170
+ const ancestorKey = nestPath.slice(0, depth).join('\0')
171
+ if (!operationsByPath.has(ancestorKey)) {
172
+ operationsByPath.set(ancestorKey, [])
173
+ }
174
+ }
175
+ const key = nestPath.join('\0')
176
+ operationsByPath.get(key)!.push(operation)
177
+ }
178
+
179
+ return [...operationsByPath].map(([key, nodeOperations]) => {
180
+ const nestPath = key ? key.split('\0') : []
181
+ return {
182
+ name: [root, ...nestPath].join(''),
183
+ root,
184
+ nestPath,
185
+ operations: nodeOperations,
186
+ serviceRefkey: ay.refkey(),
187
+ }
188
+ })
189
+ })
190
+
191
+ const childrenOf = (resource: (typeof resources)[number]) =>
192
+ resources
193
+ .filter(
194
+ (candidate) =>
195
+ candidate.root === resource.root &&
196
+ candidate.nestPath.length === resource.nestPath.length + 1 &&
197
+ resource.nestPath.every(
198
+ (segment, index) => candidate.nestPath[index] === segment,
199
+ ),
200
+ )
201
+ .map((candidate) => ({
202
+ name: candidate.nestPath.at(-1)!,
203
+ serviceRefkey: candidate.serviceRefkey,
204
+ }))
205
+
206
+ validateOperationIR(program, resources, bodyOverrides)
207
+
208
+ await emitFile(program, {
209
+ path: resolvePath(emitterOutputDir, 'README.md'),
210
+ content: readmeFile(
211
+ program,
212
+ modulePath,
213
+ pkg,
214
+ resources,
215
+ bodyOverrides,
216
+ context.options['readme-note'],
217
+ ),
218
+ })
219
+
220
+ // The header prop renders before the package clause: the Go generated-code
221
+ // convention (go/ast.IsGenerated, golangci-lint, linguist) only recognizes
222
+ // the DO NOT EDIT marker when it precedes `package`.
223
+ const fileHeader = `${generatedHeader}\n\n`
224
+ await writeOutput(
225
+ program,
226
+ <Output program={program} useTabs printWidth={1000}>
227
+ <go.ModuleDirectory name={modulePath}>
228
+ <go.SourceDirectory path="." name={pkg}>
229
+ <go.SourceFile path="doc.go" header={packageDocHeader(pkg)} />
230
+ {[...modelGroups].map(([namespace, types]) => (
231
+ <go.SourceFile
232
+ path={`models_${goFileSegment(namespace)}.go`}
233
+ header={fileHeader}
234
+ >
235
+ <GoModels program={program} types={types} />
236
+ </go.SourceFile>
237
+ ))}
238
+ {resources.map((resource) => (
239
+ <go.SourceFile path={serviceFileName(resource)} header={fileHeader}>
240
+ <GoResource
241
+ program={program}
242
+ resource={resource.root}
243
+ serviceName={resource.name}
244
+ nestPath={resource.nestPath}
245
+ operations={resource.operations}
246
+ bodyOverrides={bodyOverrides}
247
+ serviceRefkey={resource.serviceRefkey}
248
+ children={childrenOf(resource)}
249
+ />
250
+ </go.SourceFile>
251
+ ))}
252
+ <go.SourceFile path="client.go" header={fileHeader}>
253
+ <GoClient clientRefkey={clientRefkey} resources={resources} />
254
+ </go.SourceFile>
255
+ </go.SourceDirectory>
256
+ </go.ModuleDirectory>
257
+ </Output>,
258
+ emitterOutputDir,
259
+ )
260
+
261
+ const generatedFiles = [
262
+ resolvePath(emitterOutputDir, 'doc.go'),
263
+ resolvePath(emitterOutputDir, 'client.go'),
264
+ ...[...modelGroups.keys()].map((namespace) =>
265
+ resolvePath(emitterOutputDir, `models_${goFileSegment(namespace)}.go`),
266
+ ),
267
+ ...resources.map((resource) =>
268
+ resolvePath(emitterOutputDir, serviceFileName(resource)),
269
+ ),
270
+ ]
271
+ // alloy's Go printer does not column-align struct tags the way gofmt does, so
272
+ // a gofmt pass makes the generated files gofmt-clean. This couples generation
273
+ // to a Go toolchain on PATH; surface a clear error if it is missing rather
274
+ // than a raw spawn ENOENT.
275
+ try {
276
+ await execFileAsync('gofmt', ['-w', ...generatedFiles])
277
+ } catch (err) {
278
+ throw new Error(
279
+ `typespec-go: gofmt is required to format the generated Go SDK but could not run ` +
280
+ `(${(err as Error).message}). Install the Go toolchain or put gofmt on PATH.`,
281
+ )
282
+ }
283
+ }
284
+
285
+ // Removes previously generated output before emission so file renames (for
286
+ // example llmcost.go -> llm_cost.go or newly nested service files) cannot
287
+ // leave duplicate declarations behind. Hand-written Go wire tests (*_test.go
288
+ // and their testdata/ fixtures) live alongside the generated files to assert
289
+ // wire behavior against the emitted SDK; they are not emitter-owned, so they
290
+ // must survive regeneration instead of being silently wiped.
291
+ export async function cleanOutputDirectory(
292
+ host: Pick<import('@typespec/compiler').CompilerHost, 'readDir' | 'rm'>,
293
+ outputDir: string,
294
+ ): Promise<void> {
295
+ let entries: string[]
296
+ try {
297
+ entries = await host.readDir(outputDir)
298
+ } catch (error) {
299
+ if (
300
+ typeof error === 'object' &&
301
+ error !== null &&
302
+ 'code' in error &&
303
+ error.code === 'ENOENT'
304
+ ) {
305
+ return
306
+ }
307
+ throw error
308
+ }
309
+
310
+ for (const entry of entries) {
311
+ if (entry.endsWith('_test.go') || entry === 'testdata') {
312
+ continue
313
+ }
314
+ await host.rm(resolvePath(outputDir, entry), { recursive: true })
315
+ }
316
+ }
317
+
318
+ export function prepareRuntimeTemplate(
319
+ path: string,
320
+ content: string,
321
+ pkg: string,
322
+ modulePath: string,
323
+ sdkVersion: string,
324
+ goVersion: string,
325
+ ): string {
326
+ if (path === 'go.mod') {
327
+ return content
328
+ .replaceAll('{{MODULE_PATH}}', modulePath)
329
+ .replaceAll('{{GO_VERSION}}', goVersion)
330
+ }
331
+ if (!path.endsWith('.go')) {
332
+ return content
333
+ }
334
+
335
+ return `${generatedHeader}\n\n${content
336
+ .replace(/^package openmeter/m, `package ${pkg}`)
337
+ .replaceAll('{{MODULE_PATH}}', modulePath)
338
+ .replaceAll('{{SDK_VERSION}}', sdkVersion)}`
339
+ }
340
+
341
+ /** The doc.go prelude: the generated-code marker, a blank separator line so
342
+ * the marker does not become package documentation, then the package godoc
343
+ * attached to the package clause the source file renders right after it. */
344
+ export function packageDocHeader(pkg: string): string {
345
+ return (
346
+ [
347
+ generatedHeader,
348
+ '',
349
+ `// Package ${pkg} provides a Go client SDK for the OpenMeter API — usage`,
350
+ '// metering and billing for AI and DevTool companies. It is generated from the',
351
+ '// OpenMeter TypeSpec definitions and exposes every operation as a method on a',
352
+ '// typed service hanging off [Client] (for example Client.Meters), with typed',
353
+ '// request and response models.',
354
+ '//',
355
+ '// Construct a [Client] with [New], passing the API base URL and a bearer',
356
+ '// token via [WithToken]:',
357
+ '//',
358
+ `// om, err := ${pkg}.New(`,
359
+ '// "https://openmeter.cloud/api/v3",',
360
+ `// ${pkg}.WithToken(os.Getenv("OPENMETER_API_KEY")),`,
361
+ '// )',
362
+ '//',
363
+ '// Any non-2xx response is returned as an [APIError] carrying the HTTP status',
364
+ '// code and the RFC 7807 problem fields; unwrap it with [errors.As] or',
365
+ '// [AsAPIError]. Client-side validation failures such as an empty resource ID',
366
+ '// are reported before any request is sent and match [ErrEmptyID] via',
367
+ '// [errors.Is].',
368
+ '//',
369
+ '// Paginated list operations additionally provide ...All variants returning',
370
+ '// [iter.Seq2] sequences that fetch pages lazily and yield each item together',
371
+ '// with an error.',
372
+ ].join('\n') + '\n'
373
+ )
374
+ }
375
+
376
+ function serviceFileName(resource: {
377
+ root: string
378
+ nestPath: string[]
379
+ }): string {
380
+ return `${[resource.root, ...resource.nestPath]
381
+ .map(goFileSegment)
382
+ .join('_')}.go`
383
+ }
384
+
385
+ function groupTypesByOwner(
386
+ program: import('@typespec/compiler').Program,
387
+ types: Set<import('@typespec/compiler').Type>,
388
+ owners: Map<import('@typespec/compiler').Type, Set<string>>,
389
+ declarationPlan: Map<import('@typespec/compiler').Type, GoDeclarationPlan[]>,
390
+ ): Map<string, Set<import('@typespec/compiler').Type>> {
391
+ // Only types that actually render a declaration claim a models file: scalar
392
+ // aliases are pruned and structural-dedupe drops collapsed projections, so
393
+ // grouping them would leave empty generated files behind.
394
+ const emitting = new Set(
395
+ [...types].filter((type) => {
396
+ switch (type.kind) {
397
+ case 'Enum':
398
+ return optionalTypeName(program, type) !== undefined
399
+ case 'Model':
400
+ case 'Union':
401
+ return (declarationPlan.get(type)?.length ?? 0) > 0
402
+ default:
403
+ return false
404
+ }
405
+ }),
406
+ )
407
+
408
+ const ownersByName = new Map<string, Set<string>>()
409
+ for (const type of emitting) {
410
+ const name = optionalTypeName(program, type)
411
+ if (!name) {
412
+ continue
413
+ }
414
+ const namedOwners = ownersByName.get(name) ?? new Set<string>()
415
+ for (const owner of owners.get(type) ?? []) {
416
+ namedOwners.add(owner)
417
+ }
418
+ ownersByName.set(name, namedOwners)
419
+ }
420
+
421
+ const groups = new Map<string, Set<import('@typespec/compiler').Type>>()
422
+ for (const type of emitting) {
423
+ const name = optionalTypeName(program, type)
424
+ const typeOwner = name ? ownersByName.get(name) : owners.get(type)
425
+ const key = typeOwner?.size === 1 ? [...typeOwner][0]! : 'shared'
426
+ const group = groups.get(key)
427
+ if (group) {
428
+ group.add(type)
429
+ } else {
430
+ groups.set(key, new Set([type]))
431
+ }
432
+ }
433
+
434
+ // Code-point comparison, not localeCompare: the output is a committed
435
+ // artifact, and locale-dependent ordering would produce spurious diffs
436
+ // across machines.
437
+ return new Map(
438
+ [...groups].sort(([left], [right]) =>
439
+ left < right ? -1 : left > right ? 1 : 0,
440
+ ),
441
+ )
442
+ }
443
+
444
+ function goFileSegment(segment: string): string {
445
+ return segment
446
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2')
447
+ .replace(/([a-z0-9])([A-Z])/g, '$1_$2')
448
+ .toLowerCase()
449
+ }
450
+
451
+ export function validateUniqueTypeNames(
452
+ program: import('@typespec/compiler').Program,
453
+ types: Set<import('@typespec/compiler').Type>,
454
+ ): void {
455
+ const typekit = $(program)
456
+ const names = new Map<string, import('@typespec/compiler').Type>()
457
+ for (const type of types) {
458
+ if (
459
+ type.kind !== 'Model' &&
460
+ type.kind !== 'Union' &&
461
+ type.kind !== 'Enum' &&
462
+ type.kind !== 'Scalar'
463
+ ) {
464
+ continue
465
+ }
466
+ if (
467
+ type.kind === 'Model' &&
468
+ (typekit.array.is(type) || typekit.record.is(type))
469
+ ) {
470
+ continue
471
+ }
472
+ const name = optionalTypeName(program, type)
473
+ if (!name) {
474
+ continue
475
+ }
476
+ if (isRuntimeBackedTypeName(name, type.kind)) {
477
+ continue
478
+ }
479
+ if (conflictsWithReservedGoSymbol(name, type.kind)) {
480
+ throw new Error(
481
+ `typespec-go: Go type ${name} conflicts with reserved SDK runtime symbol ${name}; add a distinct @friendlyName or rename the TypeSpec construct`,
482
+ )
483
+ }
484
+ const existing = names.get(name)
485
+ if (existing && existing !== type) {
486
+ if (
487
+ existing.kind === 'Model' &&
488
+ type.kind === 'Model' &&
489
+ modelSignature(program, existing) === modelSignature(program, type)
490
+ ) {
491
+ continue
492
+ }
493
+ try {
494
+ if (
495
+ queryScalarKind(program, existing) === queryScalarKind(program, type)
496
+ ) {
497
+ continue
498
+ }
499
+ } catch {
500
+ // Non-scalar collisions are always ambiguous.
501
+ }
502
+ throw new Error(
503
+ `typespec-go: Go type name collision for ${name} (${existing.kind} and ${type.kind}); add a distinct @friendlyName instead of silently choosing one`,
504
+ )
505
+ }
506
+ names.set(name, type)
507
+ }
508
+ }
509
+
510
+ function validateOperationIR(
511
+ program: import('@typespec/compiler').Program,
512
+ resources: Array<{
513
+ root: string
514
+ nestPath: string[]
515
+ operations: import('@typespec/compiler').Operation[]
516
+ }>,
517
+ bodyOverrides: Map<string, import('@typespec/compiler').Type>,
518
+ ): void {
519
+ for (const resource of resources) {
520
+ const methods = new Set<string>()
521
+ for (const operation of describeOperations(
522
+ program,
523
+ resource.root,
524
+ resource.operations,
525
+ bodyOverrides,
526
+ resource.nestPath,
527
+ )) {
528
+ if (methods.has(operation.methodName)) {
529
+ throw new Error(
530
+ `typespec-go: duplicate method ${[resource.root, ...resource.nestPath, operation.methodName].join('.')}; add a distinct @friendlyName`,
531
+ )
532
+ }
533
+ methods.add(operation.methodName)
534
+
535
+ for (const parameter of operation.queryParams) {
536
+ if (!parameter.queryCodec) {
537
+ throw new Error(
538
+ `typespec-go: query parameter ${parameter.name} on ${operation.operation.name} has no serializer`,
539
+ )
540
+ }
541
+ }
542
+ if (operation.body && !operation.requestContentType) {
543
+ throw new Error(
544
+ `typespec-go: request body on ${operation.operation.name} has no content type`,
545
+ )
546
+ }
547
+ }
548
+ }
549
+ }
550
+
551
+ function modelSignature(
552
+ program: import('@typespec/compiler').Program,
553
+ model: import('@typespec/compiler').Model,
554
+ ): string {
555
+ const typeSignature = (
556
+ type: import('@typespec/compiler').Type,
557
+ seen = new Set<import('@typespec/compiler').Type>(),
558
+ ): string => {
559
+ if (seen.has(type)) {
560
+ return optionalTypeName(program, type) ?? type.kind
561
+ }
562
+ seen.add(type)
563
+
564
+ if (type.kind === 'Model') {
565
+ const typekit = $(program)
566
+ if (typekit.array.is(type)) {
567
+ return `[]${type.indexer ? typeSignature(type.indexer.value, seen) : '?'}`
568
+ }
569
+ if (typekit.record.is(type)) {
570
+ return `map:${type.indexer ? typeSignature(type.indexer.value, seen) : '?'}`
571
+ }
572
+ const name = optionalTypeName(program, type)
573
+ if (name) {
574
+ return `model:${name}`
575
+ }
576
+ return `model:{${[...type.properties.values()]
577
+ .map(
578
+ (property) =>
579
+ `${property.name}:${property.optional}:${property.defaultValue !== undefined}:${typeSignature(property.type, seen)}`,
580
+ )
581
+ .join(',')}}`
582
+ }
583
+ if (type.kind === 'Union') {
584
+ return `union:${[...type.variants.values()]
585
+ .map((variant) => typeSignature(variant.type, seen))
586
+ .join('|')}`
587
+ }
588
+ return `${type.kind}:${optionalTypeName(program, type) ?? ('value' in type ? String(type.value) : '')}`
589
+ }
590
+
591
+ return JSON.stringify({
592
+ base: model.baseModel
593
+ ? optionalTypeName(program, model.baseModel)
594
+ : undefined,
595
+ properties: [...model.properties.values()].map((property) => ({
596
+ name: property.name,
597
+ optional: property.optional,
598
+ defaulted: property.defaultValue !== undefined,
599
+ type: typeSignature(property.type),
600
+ })),
601
+ })
602
+ }
api/spec/packages/typespec-go/src/go-types.tsx ADDED
@@ -0,0 +1,731 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as ay from '@alloy-js/core'
2
+ import * as go from '@alloy-js/go'
3
+ import {
4
+ getFriendlyName,
5
+ resolveEncodedName,
6
+ walkPropertiesInherited,
7
+ type Model,
8
+ type ModelProperty,
9
+ type Program,
10
+ type Scalar,
11
+ type Type,
12
+ } from '@typespec/compiler'
13
+ import { $ } from '@typespec/compiler/typekit'
14
+ import { isHeader, isStatusCode, isVisible, Visibility } from '@typespec/http'
15
+
16
+ const stripNamePrefixes = new WeakMap<Program, readonly string[]>()
17
+ const resolvedTypeNames = new WeakMap<Program, Map<string, string>>()
18
+ const syntheticTypeNames = new WeakMap<Program, Map<Type, string>>()
19
+ const projectionsByProgram = new WeakMap<Program, GoProjections>()
20
+
21
+ export function inputVariantName(name: string): string {
22
+ return `${name}Input`
23
+ }
24
+
25
+ /** Which payload projection of a model a declaration or reference lives in:
26
+ * read models come from response bodies, input models from request bodies. */
27
+ export type GoPayloadMode = 'read' | 'input'
28
+
29
+ export interface GoDeclarationPlan {
30
+ name: string
31
+ mode: GoPayloadMode
32
+ }
33
+
34
+ /**
35
+ * Payload-context visibility state for one emitted program.
36
+ *
37
+ * Response-reachable models are rendered in a read context, so their
38
+ * Create-/Update-only properties (which the server never returns) are dropped.
39
+ * Request-reachable models are rendered in an input context that keeps every
40
+ * spec-projected property. A model needed by both contexts with different
41
+ * shapes is emitted twice (Name plus NameInput); `declarations` records
42
+ * exactly which projections each type emits, and `aliases` redirects
43
+ * declarations that would duplicate a structurally identical canonical type.
44
+ */
45
+ export interface GoProjections {
46
+ readReachable: Set<Type>
47
+ inputReachable: Set<Type>
48
+ divergent: Set<Type>
49
+ aliases: Map<string, string>
50
+ declarations: Map<Type, GoDeclarationPlan[]>
51
+ }
52
+
53
+ export function configureGoProjections(
54
+ program: Program,
55
+ projections: GoProjections,
56
+ ): void {
57
+ projectionsByProgram.set(program, projections)
58
+ }
59
+
60
+ export function goProjectionsOf(program: Program): GoProjections | undefined {
61
+ return projectionsByProgram.get(program)
62
+ }
63
+
64
+ export function setSyntheticTypeNames(
65
+ program: Program,
66
+ names: Map<Type, string>,
67
+ ): void {
68
+ syntheticTypeNames.set(program, names)
69
+ }
70
+
71
+ /**
72
+ * The Go type name a field or accessor must reference for `type` in the given
73
+ * payload context: the input twin when the type emits one, then any
74
+ * structural-dedupe alias pointing at the canonical declaration.
75
+ */
76
+ export function goReferenceTypeName(
77
+ program: Program,
78
+ type: Type,
79
+ name: string,
80
+ mode?: 'input' | 'output',
81
+ ): string {
82
+ const projections = goProjectionsOf(program)
83
+ if (!projections) {
84
+ return name
85
+ }
86
+
87
+ let final =
88
+ mode === 'input' && projections.divergent.has(type)
89
+ ? inputVariantName(name)
90
+ : name
91
+ while (projections.aliases.has(final)) {
92
+ final = projections.aliases.get(final)!
93
+ }
94
+ return final
95
+ }
96
+
97
+ export interface GoField {
98
+ name: string
99
+ wireName: string
100
+ type: ay.Children
101
+ /** Plain-text rendering of `type` when expressible without alloy context;
102
+ * consumed by the structural-dedupe signature. */
103
+ typeText?: string
104
+ optional: boolean
105
+ nilable: boolean
106
+ doc?: string
107
+ }
108
+
109
+ export type GoQueryScalarKind =
110
+ | 'string'
111
+ | 'boolean'
112
+ | 'integer'
113
+ | 'float'
114
+ | 'dateTime'
115
+
116
+ export type GoQueryFilterKind =
117
+ | 'string'
118
+ | 'stringExact'
119
+ | 'dateTime'
120
+ | 'numeric'
121
+ | 'boolean'
122
+ | 'labels'
123
+ | 'scalar'
124
+
125
+ export interface GoTypeOptions {
126
+ mode?: 'input' | 'output'
127
+ }
128
+
129
+ export interface GoTypeResult {
130
+ type: ay.Children
131
+ /** Plain-text rendering of `type`; undefined only for inline struct
132
+ * literals, which cannot participate in structural dedupe. */
133
+ text?: string
134
+ nilable: boolean
135
+ jsonNullable?: boolean
136
+ }
137
+
138
+ export function configureGoTypeNames(
139
+ program: Program,
140
+ prefixes: readonly string[],
141
+ ): void {
142
+ stripNamePrefixes.set(program, prefixes)
143
+ resolvedTypeNames.delete(program)
144
+ }
145
+
146
+ export function resolveGoTypeNames(program: Program, types: Iterable<Type>) {
147
+ const baseNames = [...types]
148
+ .map((type) => baseGoTypeName(program, type))
149
+ .filter((name): name is string => name !== undefined)
150
+
151
+ resolvedTypeNames.set(
152
+ program,
153
+ resolveStrippedNames(baseNames, stripNamePrefixes.get(program) ?? []),
154
+ )
155
+ }
156
+
157
+ export function goFields(
158
+ program: Program,
159
+ model: Model,
160
+ options: {
161
+ omit?: Set<string>
162
+ mode?: 'input' | 'output'
163
+ } = {},
164
+ ): GoField[] {
165
+ const fields: GoField[] = []
166
+ const projections = goProjectionsOf(program)
167
+ // Payload-context visibility: a response-reachable model is rendered in a
168
+ // read context, so Create-/Update-only properties (never returned by the
169
+ // server) are dropped from it. Request payloads render in input mode and
170
+ // keep every property the spec projected into them; without a configured
171
+ // projection registry (unit tests) nothing is filtered.
172
+ const filterRead =
173
+ options.mode !== 'input' &&
174
+ projections !== undefined &&
175
+ projections.readReachable.has(model)
176
+
177
+ for (const property of walkPropertiesInherited(model)) {
178
+ if (
179
+ isStatusCode(program, property) ||
180
+ isHeader(program, property) ||
181
+ options.omit?.has(property.name)
182
+ ) {
183
+ continue
184
+ }
185
+ if (filterRead && !isVisible(program, property, Visibility.Read)) {
186
+ continue
187
+ }
188
+
189
+ const mapped = goType(program, property.type, options)
190
+ const optional =
191
+ property.optional ||
192
+ (options.mode === 'input' && property.defaultValue !== undefined)
193
+ const pointerOptional =
194
+ optional &&
195
+ !mapped.jsonNullable &&
196
+ (!mapped.nilable || options.mode === 'input')
197
+ fields.push({
198
+ name: goExportedName(property.name),
199
+ wireName: resolveEncodedName(
200
+ program,
201
+ property as ModelProperty & { name: string },
202
+ 'application/json',
203
+ ),
204
+ type: pointerOptional ? (
205
+ <go.Pointer>{mapped.type}</go.Pointer>
206
+ ) : (
207
+ mapped.type
208
+ ),
209
+ typeText:
210
+ mapped.text === undefined
211
+ ? undefined
212
+ : `${pointerOptional ? '*' : ''}${mapped.text}`,
213
+ optional,
214
+ nilable: mapped.nilable,
215
+ doc: $(program).type.getDoc(property),
216
+ })
217
+ }
218
+
219
+ return fields
220
+ }
221
+
222
+ export function goType(
223
+ program: Program,
224
+ type: Type,
225
+ options: GoTypeOptions = {},
226
+ ): GoTypeResult {
227
+ switch (type.kind) {
228
+ case 'Boolean':
229
+ return { type: 'bool', text: 'bool', nilable: false }
230
+ case 'Number': {
231
+ const text = Number.isInteger(type.value) ? 'int' : 'float64'
232
+ return { type: text, text, nilable: false }
233
+ }
234
+ case 'String':
235
+ return { type: 'string', text: 'string', nilable: false }
236
+ case 'Scalar':
237
+ return scalarType(type)
238
+ case 'Union': {
239
+ const nullable = nullableUnionElement(type)
240
+ if (nullable) {
241
+ const element = goType(program, nullable, options)
242
+ return {
243
+ type: ay.code`Nullable[${element.type}]`,
244
+ text:
245
+ element.text === undefined
246
+ ? undefined
247
+ : `Nullable[${element.text}]`,
248
+ nilable: false,
249
+ jsonNullable: true,
250
+ }
251
+ }
252
+
253
+ const oneOrMany = oneOrManyElement(program, type)
254
+ if (oneOrMany) {
255
+ const element = goType(program, oneOrMany, options)
256
+ return {
257
+ type: ay.code`OneOrMany[${element.type}]`,
258
+ text:
259
+ element.text === undefined
260
+ ? undefined
261
+ : `OneOrMany[${element.text}]`,
262
+ nilable: false,
263
+ }
264
+ }
265
+
266
+ const name = optionalTypeName(program, type)
267
+ if (!name) {
268
+ const variants = [...type.variants.values()]
269
+ .map((variant) => variant.type)
270
+ .filter((variant) => variant.kind !== 'Intrinsic')
271
+ if (variants.length === 1) {
272
+ return goType(program, variants[0]!, options)
273
+ }
274
+ if (
275
+ variants.length > 1 &&
276
+ variants.every((variant) => variant.kind === 'String')
277
+ ) {
278
+ return { type: 'string', text: 'string', nilable: false }
279
+ }
280
+ throw new Error(
281
+ `anonymous union of [${[...type.variants.values()]
282
+ .map((variant) => variant.type.kind)
283
+ .join(
284
+ ', ',
285
+ )}] is not representable in Go; name the union or reduce it to string literals or a single concrete variant`,
286
+ )
287
+ }
288
+ const runtime = runtimeFilterTypeName(name)
289
+ if (runtime !== name) {
290
+ return { type: runtime, text: runtime, nilable: false }
291
+ }
292
+ const reference = goReferenceTypeName(program, type, name, options.mode)
293
+ return { type: reference, text: reference, nilable: false }
294
+ }
295
+ case 'Enum': {
296
+ const name = typeName(program, type)
297
+ return { type: name, text: name, nilable: false }
298
+ }
299
+ case 'EnumMember': {
300
+ const name = typeName(program, type.enum)
301
+ return { type: name, text: name, nilable: false }
302
+ }
303
+ case 'Model': {
304
+ const typekit = $(program)
305
+ if (typekit.array.is(type)) {
306
+ const element = type.indexer?.value
307
+ if (!element) {
308
+ throw new Error('array model is missing its element type')
309
+ }
310
+ const mapped = goType(program, element, options)
311
+ return {
312
+ type: ay.code`[]${mapped.type}`,
313
+ text: mapped.text === undefined ? undefined : `[]${mapped.text}`,
314
+ nilable: true,
315
+ }
316
+ }
317
+ if (typekit.record.is(type)) {
318
+ const value = type.indexer?.value
319
+ if (!value) {
320
+ throw new Error('record model is missing its value type')
321
+ }
322
+ const mapped = goType(program, value, options)
323
+ return {
324
+ type: ay.code`map[string]${mapped.type}`,
325
+ text:
326
+ mapped.text === undefined ? undefined : `map[string]${mapped.text}`,
327
+ nilable: true,
328
+ }
329
+ }
330
+
331
+ const name = optionalTypeName(program, type)
332
+ if (name) {
333
+ const reference = goReferenceTypeName(program, type, name, options.mode)
334
+ return { type: reference, text: reference, nilable: false }
335
+ }
336
+
337
+ return {
338
+ type: (
339
+ <go.StructDeclaration>
340
+ <ay.List hardline>
341
+ {goFields(program, type, options).map((field) => (
342
+ <go.StructMember
343
+ name={field.name}
344
+ type={field.type}
345
+ doc={field.doc}
346
+ tag={{
347
+ json: `${field.wireName}${field.optional ? ',omitempty' : ''}`,
348
+ }}
349
+ />
350
+ ))}
351
+ </ay.List>
352
+ </go.StructDeclaration>
353
+ ),
354
+ nilable: false,
355
+ }
356
+ }
357
+ case 'Intrinsic':
358
+ if (type.name === 'unknown') {
359
+ return { type: 'any', text: 'any', nilable: false }
360
+ }
361
+ throw new Error(
362
+ `intrinsic type ${type.name} is not representable in Go; only an explicit unknown maps to any`,
363
+ )
364
+ default:
365
+ throw new Error(`unsupported TypeSpec type kind ${type.kind}`)
366
+ }
367
+ }
368
+
369
+ // Named *FieldFilter unions are runtime-backed (see runtime-symbols.ts):
370
+ // GoModels never declares them, so every reference must resolve to one of the
371
+ // static filter types shipped in the runtime templates.
372
+ const runtimeFilterTypesByUnionName = new Map<string, string>([
373
+ ['StringFieldFilter', 'StringFilter'],
374
+ ['StringFieldFilterExact', 'StringExactFilter'],
375
+ ['ULIDFieldFilter', 'StringExactFilter'],
376
+ ['DateTimeFieldFilter', 'DateTimeFilter'],
377
+ ['NumericFieldFilter', 'NumericFilter'],
378
+ ['BooleanFieldFilter', 'BooleanFilter'],
379
+ ])
380
+
381
+ export function runtimeFilterTypeName(name: string): string {
382
+ const runtime = runtimeFilterTypesByUnionName.get(name)
383
+ if (runtime) {
384
+ return runtime
385
+ }
386
+
387
+ // Same suffix classification runtime-symbols.ts uses to skip declaring
388
+ // these unions; an unmapped one would reference an undeclared Go type.
389
+ if (name.endsWith('FieldFilter') || name.endsWith('FieldFilterExact')) {
390
+ throw new Error(
391
+ `field filter union ${name} has no runtime filter type; map it in runtimeFilterTypesByUnionName and back it with a static runtime filter`,
392
+ )
393
+ }
394
+
395
+ return name
396
+ }
397
+
398
+ export function nullableUnionElement(type: Type): Type | undefined {
399
+ if (type.kind !== 'Union') {
400
+ return undefined
401
+ }
402
+
403
+ const variants = [...type.variants.values()].map((variant) => variant.type)
404
+ const hasNull = variants.some(
405
+ (variant) => variant.kind === 'Intrinsic' && variant.name === 'null',
406
+ )
407
+ if (!hasNull) {
408
+ return undefined
409
+ }
410
+
411
+ const concrete = variants.filter(
412
+ (variant) => !(variant.kind === 'Intrinsic' && variant.name === 'null'),
413
+ )
414
+ return concrete.length === 1 ? concrete[0] : undefined
415
+ }
416
+
417
+ export function typeName(program: Program, type: Type): string {
418
+ const name = optionalTypeName(program, type)
419
+ if (!name) {
420
+ throw new Error(
421
+ `anonymous ${type.kind} cannot be emitted as a named Go type`,
422
+ )
423
+ }
424
+
425
+ return name
426
+ }
427
+
428
+ export function optionalTypeName(
429
+ program: Program,
430
+ type: Type,
431
+ ): string | undefined {
432
+ const synthetic = syntheticTypeNames.get(program)?.get(type)
433
+ if (synthetic) {
434
+ return synthetic
435
+ }
436
+
437
+ const name = baseGoTypeName(program, type)
438
+ if (!name) {
439
+ return undefined
440
+ }
441
+
442
+ const mapped =
443
+ resolvedTypeNames.get(program)?.get(name) ??
444
+ stripOnePrefix(name, stripNamePrefixes.get(program) ?? [])
445
+
446
+ switch (mapped) {
447
+ case 'PagePaginatedMeta':
448
+ return 'PaginatedMeta'
449
+ default:
450
+ return mapped
451
+ }
452
+ }
453
+
454
+ function baseGoTypeName(program: Program, type: Type): string | undefined {
455
+ const friendlyName = getFriendlyName(program, type)
456
+ const declaredName =
457
+ 'name' in type && typeof type.name === 'string' ? type.name : undefined
458
+ const name = friendlyName ?? declaredName
459
+ return name ? goExportedName(name) : undefined
460
+ }
461
+
462
+ function stripOnePrefix(name: string, prefixes: readonly string[]): string {
463
+ for (const prefix of prefixes) {
464
+ if (
465
+ prefix &&
466
+ name.length > prefix.length &&
467
+ name.startsWith(prefix) &&
468
+ /[A-Z]/.test(name[prefix.length]!)
469
+ ) {
470
+ return name.slice(prefix.length)
471
+ }
472
+ }
473
+ return name
474
+ }
475
+
476
+ function resolveStrippedNames(
477
+ names: Iterable<string>,
478
+ prefixes: readonly string[],
479
+ ): Map<string, string> {
480
+ const all = [...names]
481
+ const resolved = new Map<string, string>()
482
+
483
+ if (prefixes.length === 0) {
484
+ for (const name of all) {
485
+ resolved.set(name, name)
486
+ }
487
+ return resolved
488
+ }
489
+
490
+ const originals = new Set(all)
491
+ const candidate = new Map<string, string>()
492
+ for (const name of all) {
493
+ candidate.set(name, stripOnePrefix(name, prefixes))
494
+ }
495
+
496
+ const candidateCounts = new Map<string, number>()
497
+ for (const target of candidate.values()) {
498
+ candidateCounts.set(target, (candidateCounts.get(target) ?? 0) + 1)
499
+ }
500
+
501
+ for (const name of all) {
502
+ const target = candidate.get(name)!
503
+ const collides =
504
+ target !== name &&
505
+ (originals.has(target) || (candidateCounts.get(target) ?? 0) > 1)
506
+ resolved.set(name, collides ? name : target)
507
+ }
508
+
509
+ return resolved
510
+ }
511
+
512
+ export function goExportedName(name: string): string {
513
+ const initialisms = new Map([
514
+ ['api', 'API'],
515
+ ['csv', 'CSV'],
516
+ ['http', 'HTTP'],
517
+ ['id', 'ID'],
518
+ ['json', 'JSON'],
519
+ ['llm', 'LLM'],
520
+ ['sql', 'SQL'],
521
+ ['ulid', 'ULID'],
522
+ ['url', 'URL'],
523
+ ['uuid', 'UUID'],
524
+ ])
525
+
526
+ return name
527
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
528
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
529
+ .split(/[\s\-_./]+/)
530
+ .filter(Boolean)
531
+ .map((part) => {
532
+ const initialism = initialisms.get(part.toLowerCase())
533
+ return (
534
+ initialism ?? part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()
535
+ )
536
+ })
537
+ .join('')
538
+ }
539
+
540
+ export function queryScalarKind(
541
+ program: Program,
542
+ type: Type,
543
+ ): GoQueryScalarKind {
544
+ switch (type.kind) {
545
+ case 'String':
546
+ return 'string'
547
+ case 'Boolean':
548
+ return 'boolean'
549
+ case 'Number':
550
+ return Number.isInteger(type.value) ? 'integer' : 'float'
551
+ case 'Enum': {
552
+ const numeric = [...type.members.values()].some(
553
+ (member) => typeof member.value === 'number',
554
+ )
555
+ return numeric ? 'integer' : 'string'
556
+ }
557
+ case 'EnumMember':
558
+ return queryScalarKind(program, type.enum)
559
+ case 'Union': {
560
+ const concrete = [...type.variants.values()]
561
+ .map((variant) => variant.type)
562
+ .filter((variant) => variant.kind !== 'Intrinsic')
563
+ if (concrete.length === 0) {
564
+ throw new Error('query union has no concrete variants')
565
+ }
566
+ const kinds = new Set(
567
+ concrete.map((variant) => queryScalarKind(program, variant)),
568
+ )
569
+ if (kinds.size !== 1) {
570
+ throw new Error(
571
+ `query union ${optionalTypeName(program, type) ?? '<anonymous>'} mixes incompatible scalar kinds`,
572
+ )
573
+ }
574
+ return [...kinds][0]!
575
+ }
576
+ case 'Scalar':
577
+ return scalarQueryKind(type)
578
+ default:
579
+ throw new Error(
580
+ `unsupported ${type.kind} query scalar ${optionalTypeName(program, type) ?? '<anonymous>'}`,
581
+ )
582
+ }
583
+ }
584
+
585
+ export function queryFilterKind(
586
+ program: Program,
587
+ type: Type,
588
+ ): GoQueryFilterKind {
589
+ switch (optionalTypeName(program, type)) {
590
+ case 'StringFieldFilter':
591
+ return 'string'
592
+ case 'StringFieldFilterExact':
593
+ case 'ULIDFieldFilter':
594
+ return 'stringExact'
595
+ case 'DateTimeFieldFilter':
596
+ return 'dateTime'
597
+ case 'NumericFieldFilter':
598
+ return 'numeric'
599
+ case 'BooleanFieldFilter':
600
+ return 'boolean'
601
+ case 'LabelsFieldFilter':
602
+ return 'labels'
603
+ default:
604
+ queryScalarKind(program, type)
605
+ return 'scalar'
606
+ }
607
+ }
608
+
609
+ function scalarType(scalar: Scalar): GoTypeResult {
610
+ for (
611
+ let current: Scalar | undefined = scalar;
612
+ current;
613
+ current = current.baseScalar
614
+ ) {
615
+ switch (current.name) {
616
+ case 'DateTime':
617
+ case 'utcDateTime':
618
+ case 'offsetDateTime':
619
+ return { type: go.std.time.Time, text: 'time.Time', nilable: false }
620
+ case 'Numeric':
621
+ return { type: 'Numeric', text: 'Numeric', nilable: false }
622
+ case 'boolean':
623
+ return { type: 'bool', text: 'bool', nilable: false }
624
+ case 'integer':
625
+ case 'safeint':
626
+ // `integer` is arbitrary-precision and `safeint` spans 53 bits on the
627
+ // wire, so neither fits a narrower sized Go integer by declaration.
628
+ return { type: 'int64', text: 'int64', nilable: false }
629
+ case 'int8':
630
+ return { type: 'int8', text: 'int8', nilable: false }
631
+ case 'int16':
632
+ return { type: 'int16', text: 'int16', nilable: false }
633
+ case 'int32':
634
+ return { type: 'int32', text: 'int32', nilable: false }
635
+ case 'int64':
636
+ return { type: 'int64', text: 'int64', nilable: false }
637
+ case 'uint8':
638
+ return { type: 'uint8', text: 'uint8', nilable: false }
639
+ case 'uint16':
640
+ return { type: 'uint16', text: 'uint16', nilable: false }
641
+ case 'uint32':
642
+ return { type: 'uint32', text: 'uint32', nilable: false }
643
+ case 'uint64':
644
+ return { type: 'uint64', text: 'uint64', nilable: false }
645
+ case 'float':
646
+ case 'float64':
647
+ return { type: 'float64', text: 'float64', nilable: false }
648
+ case 'float32':
649
+ return { type: 'float32', text: 'float32', nilable: false }
650
+ case 'decimal':
651
+ case 'decimal128':
652
+ return { type: 'Numeric', text: 'Numeric', nilable: false }
653
+ case 'string':
654
+ return { type: 'string', text: 'string', nilable: false }
655
+ default:
656
+ break
657
+ }
658
+ }
659
+
660
+ return { type: 'string', text: 'string', nilable: false }
661
+ }
662
+
663
+ function scalarQueryKind(scalar: Scalar): GoQueryScalarKind {
664
+ for (
665
+ let current: Scalar | undefined = scalar;
666
+ current;
667
+ current = current.baseScalar
668
+ ) {
669
+ switch (current.name) {
670
+ case 'DateTime':
671
+ case 'utcDateTime':
672
+ case 'offsetDateTime':
673
+ return 'dateTime'
674
+ case 'boolean':
675
+ return 'boolean'
676
+ case 'integer':
677
+ case 'int8':
678
+ case 'int16':
679
+ case 'int32':
680
+ case 'int64':
681
+ case 'safeint':
682
+ case 'uint8':
683
+ case 'uint16':
684
+ case 'uint32':
685
+ case 'uint64':
686
+ return 'integer'
687
+ case 'float':
688
+ case 'float32':
689
+ case 'float64':
690
+ case 'decimal':
691
+ case 'decimal128':
692
+ return 'float'
693
+ case 'string':
694
+ case 'Numeric':
695
+ return 'string'
696
+ default:
697
+ break
698
+ }
699
+ }
700
+
701
+ return 'string'
702
+ }
703
+
704
+ export function oneOrManyElement(
705
+ program: Program,
706
+ type: Type,
707
+ ): Type | undefined {
708
+ if (type.kind !== 'Union') {
709
+ return undefined
710
+ }
711
+
712
+ const variants = [...type.variants.values()]
713
+ .map((variant) => variant.type)
714
+ .filter((variant) => variant.kind !== 'Intrinsic')
715
+ if (variants.length !== 2) {
716
+ return undefined
717
+ }
718
+
719
+ const typekit = $(program)
720
+ const array = variants.find(
721
+ (variant): variant is Model =>
722
+ variant.kind === 'Model' && typekit.array.is(variant),
723
+ )
724
+ const single = variants.find((variant) => variant !== array)
725
+ const element = array?.indexer?.value
726
+ if (!array || !single || !element) {
727
+ return undefined
728
+ }
729
+
730
+ return element === single ? single : undefined
731
+ }
api/spec/packages/typespec-go/src/grouping.ts ADDED
@@ -0,0 +1,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ getFriendlyName,
3
+ type Operation,
4
+ type Program,
5
+ } from '@typespec/compiler'
6
+ import { getOperationId } from '@typespec/openapi'
7
+
8
+ const NOISE_TOKENS = new Set(['metering'])
9
+ const ACRONYMS = new Map([
10
+ ['csv', 'CSV'],
11
+ ['json', 'JSON'],
12
+ ])
13
+ const SPLIT_BY_INTERFACE = new Set(['ProductCatalog'])
14
+
15
+ function lowerFirst(name: string): string {
16
+ const match = name.match(/^([A-Z]{2,})([A-Z][a-z].*)$/)
17
+ if (match?.[1] && match[2]) {
18
+ return match[1].toLowerCase() + match[2]
19
+ }
20
+
21
+ return name.charAt(0).toLowerCase() + name.slice(1)
22
+ }
23
+
24
+ export function resourceWords(resource: string): string[] {
25
+ return resource
26
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
27
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
28
+ .split(/[\s\-_]+/)
29
+ .filter(Boolean)
30
+ .map((word) => word.toLowerCase())
31
+ }
32
+
33
+ function resourceTokens(
34
+ resource: string,
35
+ nestPath: string[] = [],
36
+ ): Set<string> {
37
+ const tokens = new Set(NOISE_TOKENS)
38
+ for (const word of [
39
+ ...resourceWords(resource),
40
+ ...nestPath.flatMap(resourceWords),
41
+ ]) {
42
+ tokens.add(word)
43
+ tokens.add(word.endsWith('s') ? word.slice(0, -1) : `${word}s`)
44
+ }
45
+
46
+ return tokens
47
+ }
48
+
49
+ export function methodNameOf(
50
+ program: Program,
51
+ operation: Operation,
52
+ resource: string,
53
+ nestPath: string[] = [],
54
+ ): string {
55
+ const operationID =
56
+ getFriendlyName(program, operation) ??
57
+ getOperationId(program, operation) ??
58
+ operation.name
59
+ const strip = resourceTokens(resource, nestPath)
60
+ const kept = resourceWords(operationID).filter((part) => !strip.has(part))
61
+ const parts = kept.length > 0 ? kept : resourceWords(operationID)
62
+
63
+ return exportedMethodName(parts)
64
+ }
65
+
66
+ export function methodNameFromOperationName(operationName: string): string {
67
+ return exportedMethodName(resourceWords(operationName))
68
+ }
69
+
70
+ function exportedMethodName(parts: string[]): string {
71
+ return parts
72
+ .map(
73
+ (part) =>
74
+ ACRONYMS.get(part) ?? part.charAt(0).toUpperCase() + part.slice(1),
75
+ )
76
+ .join('')
77
+ }
78
+
79
+ export function sourceOf(operation: Operation): {
80
+ chain: string[]
81
+ interface?: string
82
+ } {
83
+ const source =
84
+ operation.interface?.sourceInterfaces?.[0] ??
85
+ operation.sourceOperation?.interface
86
+ const chain: string[] = []
87
+
88
+ for (
89
+ let namespace = source?.namespace;
90
+ namespace?.name;
91
+ namespace = namespace.namespace
92
+ ) {
93
+ chain.unshift(namespace.name)
94
+ }
95
+
96
+ return { chain, interface: source?.name }
97
+ }
98
+
99
+ export function operationNestPath(
100
+ operation: Operation,
101
+ resource: string,
102
+ ): string[] {
103
+ const { chain } = sourceOf(operation)
104
+ if (SPLIT_BY_INTERFACE.has(chain[0] ?? '') || chain[0] !== resource) {
105
+ return []
106
+ }
107
+
108
+ return chain.slice(1)
109
+ }
110
+
111
+ function interfaceResource(interfaceName: string): string {
112
+ return pluralize(interfaceName.replace(/Operations$/, ''))
113
+ }
114
+
115
+ export function groupOperations(
116
+ operations: Operation[],
117
+ ): Map<string, Operation[]> {
118
+ const groups = new Map<string, Operation[]>()
119
+
120
+ for (const operation of operations) {
121
+ const { chain, interface: sourceInterface } = sourceOf(operation)
122
+ const top = chain[0]
123
+ if (!top) {
124
+ const qualifiedName = operation.interface
125
+ ? `${operation.interface.name}.${operation.name}`
126
+ : operation.name
127
+ throw new Error(
128
+ `typespec-go: cannot place operation ${qualifiedName} in a resource group: its source declaration is not inside a named namespace. Declare it in an interface that extends a resource namespace interface (for example \`interface Endpoints extends Customers.Operations\`) or reference a namespaced operation with \`is\`, so the emitter knows which Go sub-client owns it.`,
129
+ )
130
+ }
131
+
132
+ const resource =
133
+ SPLIT_BY_INTERFACE.has(top) && sourceInterface
134
+ ? interfaceResource(sourceInterface)
135
+ : top
136
+ const existing = groups.get(resource)
137
+ if (existing) {
138
+ existing.push(operation)
139
+ } else {
140
+ groups.set(resource, [operation])
141
+ }
142
+ }
143
+
144
+ return groups
145
+ }
146
+
147
+ export function pluralize(word: string): string {
148
+ if (word.endsWith('s')) {
149
+ return word
150
+ }
151
+ if (/(x|z|ch|sh)$/i.test(word)) {
152
+ return `${word}es`
153
+ }
154
+ if (/[^aeiou]y$/i.test(word)) {
155
+ return `${word.slice(0, -1)}ies`
156
+ }
157
+
158
+ return `${word}s`
159
+ }
160
+
161
+ export function namespaceNames(resource: string): {
162
+ class: string
163
+ getter: string
164
+ } {
165
+ const className = resource.charAt(0).toUpperCase() + resource.slice(1)
166
+ return { class: className, getter: lowerFirst(className) }
167
+ }
api/spec/packages/typespec-go/src/index.ts ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ export { $onEmit } from './emitter.jsx'
2
+ export { $lib } from './lib.js'
api/spec/packages/typespec-go/src/lib.ts ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createTypeSpecLibrary, JSONSchemaType } from '@typespec/compiler'
2
+
3
+ export interface GoEmitterOptions {
4
+ /** Go module path of the generated SDK, e.g. github.com/openmeterio/openmeter/sdk/go/openmeter. */
5
+ 'module-path': string
6
+ /** Go package name for the flat single-package SDK, e.g. "openmeter". */
7
+ 'package-name': string
8
+ /** Markdown inserted after the README intro, e.g. a GitHub alert callout. */
9
+ 'readme-note'?: string
10
+ /**
11
+ * Fallback SDK version used when Go build info is unavailable (the module
12
+ * itself, replace directives, vendored trees without version data). Module
13
+ * consumers instead get their resolved module version from
14
+ * debug.ReadBuildInfo() at runtime, so tagged releases need no stamping
15
+ * commit. Defaults to the 0.0.0-dev placeholder.
16
+ */
17
+ 'sdk-version'?: string
18
+ /**
19
+ * Service namespace names whose operations are emitted as sub-clients. When
20
+ * omitted, all services are included. Mirrors the TypeScript emitter's knob.
21
+ */
22
+ 'include-services'?: string[]
23
+ /** PascalCase type-name prefixes to strip when doing so is unambiguous. */
24
+ 'strip-name-prefixes'?: string[]
25
+ /**
26
+ * Operation-group names to generate. When omitted, every discovered group is
27
+ * emitted.
28
+ */
29
+ 'include-resources'?: string[]
30
+ /**
31
+ * Minimum Go version stamped into the generated go.mod's `go` directive.
32
+ * Defaults to 1.23, the generated code's actual floor (the iter package).
33
+ * Raise it when repo-preserved *_test.go files need newer stdlib APIs; doing
34
+ * so raises the consumer floor for every SDK user, not just this repo.
35
+ */
36
+ 'go-version'?: string
37
+ }
38
+
39
+ const EmitterOptionsSchema: JSONSchemaType<GoEmitterOptions> = {
40
+ type: 'object',
41
+ additionalProperties: true,
42
+ properties: {
43
+ 'module-path': {
44
+ type: 'string',
45
+ description: 'Go module path of the generated SDK.',
46
+ },
47
+ 'package-name': {
48
+ type: 'string',
49
+ description: 'Go package name for the flat single-package SDK.',
50
+ },
51
+ 'readme-note': {
52
+ type: 'string',
53
+ nullable: true,
54
+ description:
55
+ 'Markdown inserted after the README intro, e.g. a GitHub alert callout.',
56
+ },
57
+ 'sdk-version': {
58
+ type: 'string',
59
+ nullable: true,
60
+ description:
61
+ 'Fallback SDK version used when Go build info is unavailable (module consumers instead get their resolved module version at runtime). Defaults to 0.0.0-dev.',
62
+ },
63
+ 'include-services': {
64
+ type: 'array',
65
+ items: { type: 'string' },
66
+ nullable: true,
67
+ description:
68
+ 'Service namespace names whose operations are emitted as sub-clients. When omitted, all services are included.',
69
+ },
70
+ 'strip-name-prefixes': {
71
+ type: 'array',
72
+ items: { type: 'string' },
73
+ nullable: true,
74
+ description:
75
+ 'PascalCase type-name prefixes to strip when doing so is unambiguous.',
76
+ },
77
+ 'include-resources': {
78
+ type: 'array',
79
+ items: { type: 'string' },
80
+ nullable: true,
81
+ description:
82
+ 'Operation-group names to generate. Defaults to every discovered group.',
83
+ },
84
+ 'go-version': {
85
+ type: 'string',
86
+ nullable: true,
87
+ description:
88
+ "Minimum Go version stamped into the generated go.mod's go directive. Defaults to 1.23, the generated code's actual floor (the iter package). Raise it when repo-preserved *_test.go files need newer stdlib APIs, noting it raises the consumer floor.",
89
+ },
90
+ },
91
+ required: ['module-path', 'package-name'],
92
+ }
93
+
94
+ export const $lib = createTypeSpecLibrary({
95
+ name: 'typespec-go',
96
+ emitter: {
97
+ options: EmitterOptionsSchema,
98
+ },
99
+ diagnostics: {},
100
+ })
101
+
102
+ export const { reportDiagnostic, createDiagnostic } = $lib
api/spec/packages/typespec-go/src/operations.ts ADDED
@@ -0,0 +1,470 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ getFriendlyName,
3
+ type Model,
4
+ type ModelProperty,
5
+ type Operation,
6
+ type Program,
7
+ type Type,
8
+ } from '@typespec/compiler'
9
+ import { $ } from '@typespec/compiler/typekit'
10
+ import {
11
+ getAllHttpServices,
12
+ type HttpOperationParameter,
13
+ type HttpOperationQueryParameter,
14
+ type HttpStatusCodesEntry,
15
+ } from '@typespec/http'
16
+ import '@typespec/http/experimental/typekit'
17
+ import { getOperationId } from '@typespec/openapi'
18
+ import { optionalTypeName } from './go-types.js'
19
+ import { methodNameFromOperationName, methodNameOf } from './grouping.js'
20
+
21
+ export type GoQueryCodec =
22
+ | { kind: 'page' }
23
+ | { kind: 'cursorPage' }
24
+ | { kind: 'sort' }
25
+ | { kind: 'deepObject'; model: Model }
26
+ | { kind: 'array'; explode: boolean }
27
+ | { kind: 'scalar' }
28
+
29
+ export interface GoOperation {
30
+ operation: Operation
31
+ methodName: string
32
+ verb: string
33
+ path: string
34
+ pathParams: GoParameter[]
35
+ queryParams: GoParameter[]
36
+ body?: Type
37
+ bodyOptional: boolean
38
+ requestContentType?: string
39
+ response?: Type
40
+ responseContentType?: string
41
+ pagination?: 'page' | 'cursor'
42
+ }
43
+
44
+ export interface GoParameter {
45
+ name: string
46
+ property: ModelProperty
47
+ type: Type
48
+ queryCodec?: GoQueryCodec
49
+ }
50
+
51
+ export function collectHttpOperations(
52
+ program: Program,
53
+ includeServices?: string[],
54
+ ): Operation[] {
55
+ const [services] = getAllHttpServices(program)
56
+ const included =
57
+ includeServices && includeServices.length > 0
58
+ ? services.filter((service) =>
59
+ includeServices.includes(service.namespace.name),
60
+ )
61
+ : services
62
+ const seen = new Set<string>()
63
+ const operations: Operation[] = []
64
+
65
+ for (const service of included) {
66
+ for (const httpOperation of service.operations) {
67
+ const operation = httpOperation.operation
68
+ const identity = operationRepresentationKey(program, operation)
69
+ if (seen.has(identity)) {
70
+ continue
71
+ }
72
+ seen.add(identity)
73
+ operations.push(operation)
74
+ }
75
+ }
76
+
77
+ return operations
78
+ }
79
+
80
+ export function describeOperations(
81
+ program: Program,
82
+ resource: string,
83
+ operations: Operation[],
84
+ bodyOverrides: Map<string, Type> = new Map(),
85
+ nestPath: string[] = [],
86
+ ): GoOperation[] {
87
+ const typekit = $(program)
88
+
89
+ const described = operations.map((operation) => {
90
+ const httpOperation = typekit.httpOperation.get(operation)
91
+ assertSupportedParameters(
92
+ operation,
93
+ httpOperation.parameters.parameters,
94
+ httpOperation.parameters.body?.contentTypeProperty,
95
+ )
96
+ if (
97
+ httpOperation.parameters.body &&
98
+ httpOperation.parameters.body.bodyKind !== 'single'
99
+ ) {
100
+ throw new Error(
101
+ `typespec-go: unsupported ${httpOperation.parameters.body.bodyKind} request body on ${operation.name}; add an explicit Go body codec before emitting it`,
102
+ )
103
+ }
104
+ const pathParams = httpOperation.parameters.parameters
105
+ .filter((parameter) => parameter.type === 'path')
106
+ .map((parameter) => ({
107
+ name: parameter.name,
108
+ property: parameter.param,
109
+ type: parameter.param.type,
110
+ }))
111
+ const queryParams = httpOperation.parameters.parameters
112
+ .filter((parameter) => parameter.type === 'query')
113
+ .map((parameter) => ({
114
+ name: parameter.name,
115
+ property: parameter.param,
116
+ type: parameter.param.type,
117
+ queryCodec: classifyQueryParameter(program, operation, parameter),
118
+ }))
119
+ const response = successBody(program, operation, httpOperation.responses)
120
+ const overrideKey = qualifiedOperationKey(operation)
121
+ const body =
122
+ bodyOverrides.get(overrideKey) ?? httpOperation.parameters.body?.type
123
+ const requestContentType = bodyOverrides.has(overrideKey)
124
+ ? 'application/json'
125
+ : preferredContentType(httpOperation.parameters.body?.contentTypes)
126
+
127
+ return {
128
+ operation,
129
+ methodName: methodNameOf(program, operation, resource, nestPath),
130
+ verb: httpOperation.verb,
131
+ path: httpOperation.path,
132
+ pathParams,
133
+ queryParams,
134
+ body,
135
+ bodyOptional:
136
+ body !== undefined &&
137
+ !bodyOverrides.has(overrideKey) &&
138
+ (httpOperation.parameters.body?.property?.optional ?? false),
139
+ requestContentType,
140
+ response:
141
+ response &&
142
+ (optionalTypeName(program, response.type)
143
+ ? response.type
144
+ : response.envelope),
145
+ responseContentType: response?.contentType,
146
+ pagination: paginationKind(queryParams),
147
+ }
148
+ })
149
+
150
+ return disambiguateMethodNames(described)
151
+ }
152
+
153
+ export function classifyQueryParameter(
154
+ program: Program,
155
+ operation: Operation,
156
+ parameter: HttpOperationQueryParameter,
157
+ ): GoQueryCodec {
158
+ const typekit = $(program)
159
+ const type = parameter.param.type
160
+ if (parameter.name === 'sort') {
161
+ return { kind: 'sort' }
162
+ }
163
+
164
+ if (type.kind === 'Model') {
165
+ if (typekit.array.is(type)) {
166
+ return { kind: 'array', explode: parameter.explode }
167
+ }
168
+
169
+ if (parameter.style === 'deepObject') {
170
+ // Only a parameter literally named `page` may become a pagination
171
+ // codec, and its property set must match one pagination shape exactly;
172
+ // otherwise a coincidentally shaped filter model would be silently
173
+ // reclassified and lose its remaining properties.
174
+ if (parameter.name !== 'page') {
175
+ return { kind: 'deepObject', model: type }
176
+ }
177
+
178
+ const properties = new Set(type.properties.keys())
179
+ const within = (allowed: readonly string[]) =>
180
+ [...properties].every((key) => allowed.includes(key))
181
+ if (
182
+ (properties.has('after') || properties.has('before')) &&
183
+ within(['size', 'after', 'before'])
184
+ ) {
185
+ return { kind: 'cursorPage' }
186
+ }
187
+ if (properties.has('number') && within(['size', 'number'])) {
188
+ return { kind: 'page' }
189
+ }
190
+
191
+ throw new Error(
192
+ `typespec-go: query parameter page on ${operation.name} has properties {${[...properties].join(', ')}} matching neither page pagination {size, number} nor cursor pagination {size, after, before}; rename the parameter or align it with a pagination shape before emitting it`,
193
+ )
194
+ }
195
+ }
196
+
197
+ return { kind: 'scalar' }
198
+ }
199
+
200
+ /**
201
+ * Request body overrides for @sharedRoute siblings.
202
+ *
203
+ * TypeSpec exposes the CSV meter query as a response-only sibling of the JSON
204
+ * query. Both operations share an operation id, so the JSON sibling's request
205
+ * body is also the request body for the CSV method.
206
+ *
207
+ * Shared-route variants that already declare their own body keep that body and
208
+ * content type. That lets the Go SDK expose explicit media-type-specific
209
+ * overloads such as CloudEvents single-event, CloudEvents batch, and generic
210
+ * application/json ingest.
211
+ *
212
+ * Siblings are associated by their shared route (declaring container + verb +
213
+ * path) rather than by operation id or name, so two unrelated operations that
214
+ * happen to share a name in different namespaces can never donate a body to
215
+ * each other. The returned map is keyed by qualifiedOperationKey.
216
+ */
217
+ export function jsonBodyOverrides(program: Program): Map<string, Type> {
218
+ const typekit = $(program)
219
+ const [services] = getAllHttpServices(program)
220
+ const bodylessRoutes = new Map<string, string>()
221
+ const jsonBodyByRoute = new Map<string, Type>()
222
+
223
+ for (const service of services) {
224
+ for (const httpOperation of service.operations) {
225
+ const operation = httpOperation.operation
226
+ const routeKey = [
227
+ containerPath(operation).join('.'),
228
+ httpOperation.verb,
229
+ httpOperation.path,
230
+ ].join('|')
231
+ const body = typekit.httpOperation.get(operation).parameters.body
232
+
233
+ if (!body?.type) {
234
+ bodylessRoutes.set(qualifiedOperationKey(operation), routeKey)
235
+ } else if (body.contentTypes.includes('application/json')) {
236
+ jsonBodyByRoute.set(routeKey, body.type)
237
+ }
238
+ }
239
+ }
240
+
241
+ const overrides = new Map<string, Type>()
242
+ for (const [operationKey, routeKey] of bodylessRoutes) {
243
+ const json = jsonBodyByRoute.get(routeKey)
244
+ if (json) {
245
+ overrides.set(operationKey, json)
246
+ }
247
+ }
248
+
249
+ return overrides
250
+ }
251
+
252
+ function qualifiedOperationKey(operation: Operation): string {
253
+ return [...containerPath(operation), operation.name].join('.')
254
+ }
255
+
256
+ function containerPath(operation: Operation): string[] {
257
+ const path: string[] = []
258
+ for (
259
+ let namespace = operation.interface?.namespace ?? operation.namespace;
260
+ namespace?.name;
261
+ namespace = namespace.namespace
262
+ ) {
263
+ path.unshift(namespace.name)
264
+ }
265
+ if (operation.interface) {
266
+ path.push(operation.interface.name)
267
+ }
268
+
269
+ return path
270
+ }
271
+
272
+ export function operationBaseName(
273
+ program: Program,
274
+ operation: Operation,
275
+ ): string {
276
+ const identity =
277
+ getFriendlyName(program, operation) ??
278
+ getOperationId(program, operation) ??
279
+ operation.name
280
+
281
+ return identity
282
+ .split(/[-_/\s]+/)
283
+ .filter(Boolean)
284
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
285
+ .join('')
286
+ }
287
+
288
+ function operationRepresentationKey(
289
+ program: Program,
290
+ operation: Operation,
291
+ ): string {
292
+ const httpOperation = $(program).httpOperation.get(operation)
293
+ const body = httpOperation.parameters.body
294
+ const response = successBody(program, operation, httpOperation.responses)
295
+
296
+ return [
297
+ operationBaseName(program, operation),
298
+ httpOperation.verb,
299
+ httpOperation.path,
300
+ body?.contentTypes.join(',') ?? '',
301
+ typeKey(program, body?.type),
302
+ response?.contentType ?? '',
303
+ typeKey(program, response?.type),
304
+ ].join('|')
305
+ }
306
+
307
+ function typeKey(
308
+ program: Program,
309
+ type: Type | undefined,
310
+ seen = new Set<Type>(),
311
+ ): string {
312
+ if (!type) {
313
+ return ''
314
+ }
315
+
316
+ const named = optionalTypeName(program, type)
317
+ if (named) {
318
+ return `${type.kind}:${named}`
319
+ }
320
+
321
+ if (seen.has(type)) {
322
+ return type.kind
323
+ }
324
+ seen.add(type)
325
+
326
+ switch (type.kind) {
327
+ case 'Model': {
328
+ const typekit = $(program)
329
+ if (typekit.array.is(type)) {
330
+ return `array:${typeKey(program, type.indexer?.value, seen)}`
331
+ }
332
+ if (typekit.record.is(type)) {
333
+ return `record:${typeKey(program, type.indexer?.value, seen)}`
334
+ }
335
+ return `model:{${[...type.properties.values()]
336
+ .map(
337
+ (property) =>
338
+ `${property.name}:${typeKey(program, property.type, seen)}`,
339
+ )
340
+ .join(',')}}`
341
+ }
342
+ case 'Union':
343
+ return `union:${[...type.variants.values()]
344
+ .map((variant) => typeKey(program, variant.type, seen))
345
+ .join('|')}`
346
+ case 'Tuple':
347
+ return `tuple:${type.values
348
+ .map((value) => typeKey(program, value, seen))
349
+ .join('|')}`
350
+ default:
351
+ return `${type.kind}:${'value' in type ? String(type.value) : ''}`
352
+ }
353
+ }
354
+
355
+ function disambiguateMethodNames(operations: GoOperation[]): GoOperation[] {
356
+ const byMethodName = new Map<string, GoOperation[]>()
357
+ for (const operation of operations) {
358
+ const group = byMethodName.get(operation.methodName)
359
+ if (group) {
360
+ group.push(operation)
361
+ } else {
362
+ byMethodName.set(operation.methodName, [operation])
363
+ }
364
+ }
365
+
366
+ for (const group of byMethodName.values()) {
367
+ if (group.length < 2) {
368
+ continue
369
+ }
370
+
371
+ for (const operation of group) {
372
+ operation.methodName = methodNameFromOperationName(
373
+ operation.operation.name,
374
+ )
375
+ }
376
+ }
377
+
378
+ return operations
379
+ }
380
+
381
+ function assertSupportedParameters(
382
+ operation: Operation,
383
+ parameters: HttpOperationParameter[],
384
+ contentTypeProperty: ModelProperty | undefined,
385
+ ): void {
386
+ for (const parameter of parameters) {
387
+ if (parameter.type === 'path' || parameter.type === 'query') {
388
+ continue
389
+ }
390
+ if (
391
+ parameter.type === 'header' &&
392
+ contentTypeProperty &&
393
+ parameter.param === contentTypeProperty
394
+ ) {
395
+ continue
396
+ }
397
+
398
+ throw new Error(
399
+ `typespec-go: unsupported ${parameter.type} parameter ${parameter.name} on ${operation.name}; add an explicit Go ${parameter.type} codec before emitting it`,
400
+ )
401
+ }
402
+ }
403
+
404
+ function is2xx(status: HttpStatusCodesEntry): boolean {
405
+ return (
406
+ status === '*' ||
407
+ (typeof status === 'number' && status >= 200 && status < 300) ||
408
+ (typeof status === 'object' && status.start >= 200 && status.start < 300)
409
+ )
410
+ }
411
+
412
+ function successBody(
413
+ program: Program,
414
+ operation: Operation,
415
+ responses: ReturnType<
416
+ ReturnType<typeof $>['httpOperation']['get']
417
+ >['responses'],
418
+ ): { type: Type; envelope: Type; contentType?: string } | undefined {
419
+ let found: { type: Type; envelope: Type; contentType?: string } | undefined
420
+ let foundKey: string | undefined
421
+
422
+ for (const response of responses) {
423
+ if (!is2xx(response.statusCodes)) {
424
+ continue
425
+ }
426
+ for (const content of response.responses) {
427
+ if (!content.body?.type) {
428
+ continue
429
+ }
430
+ if (found === undefined) {
431
+ found = {
432
+ type: content.body.type,
433
+ envelope: response.type,
434
+ contentType: content.body.contentTypes[0],
435
+ }
436
+ foundKey = typeKey(program, content.body.type)
437
+ } else if (foundKey !== typeKey(program, content.body.type)) {
438
+ const describe = (type: Type) =>
439
+ optionalTypeName(program, type) ?? type.kind
440
+ throw new Error(
441
+ `typespec-go: operation ${operation.name} declares multiple 2xx response bodies with different types (${describe(found.type)} vs ${describe(content.body.type)}); split the variants into @sharedRoute siblings or align the response models before emitting it`,
442
+ )
443
+ }
444
+ }
445
+ }
446
+
447
+ return found
448
+ }
449
+
450
+ function preferredContentType(
451
+ contentTypes: readonly string[] | undefined,
452
+ ): string | undefined {
453
+ return (
454
+ contentTypes?.find((contentType) => contentType === 'application/json') ??
455
+ contentTypes?.[0]
456
+ )
457
+ }
458
+
459
+ function paginationKind(queryParams: GoParameter[]): GoOperation['pagination'] {
460
+ for (const parameter of queryParams) {
461
+ if (parameter.queryCodec?.kind === 'page') {
462
+ return 'page'
463
+ }
464
+ if (parameter.queryCodec?.kind === 'cursorPage') {
465
+ return 'cursor'
466
+ }
467
+ }
468
+
469
+ return undefined
470
+ }
api/spec/packages/typespec-go/src/projections.ts ADDED
@@ -0,0 +1,1039 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ resolveEncodedName,
3
+ walkPropertiesInherited,
4
+ type Model,
5
+ type Operation,
6
+ type Program,
7
+ type Type,
8
+ type Union,
9
+ } from '@typespec/compiler'
10
+ import { $ } from '@typespec/compiler/typekit'
11
+ import { isHeader, isStatusCode, isVisible, Visibility } from '@typespec/http'
12
+ import {
13
+ goExportedName,
14
+ goType,
15
+ inputVariantName,
16
+ nullableUnionElement,
17
+ oneOrManyElement,
18
+ optionalTypeName,
19
+ runtimeFilterTypeName,
20
+ type GoDeclarationPlan,
21
+ type GoPayloadMode,
22
+ } from './go-types.js'
23
+ import { isStringLike } from './components/GoModels.js'
24
+ import {
25
+ discriminatorLiteral,
26
+ discriminatorProperty,
27
+ variantAccessorName,
28
+ } from './components/GoUnion.js'
29
+ import { isRuntimeBackedTypeName } from './runtime-symbols.js'
30
+ import { describeOperations } from './operations.js'
31
+
32
+ export interface GoReachability {
33
+ /** Types reachable from response bodies, path params, and query params. */
34
+ readReachable: Set<Type>
35
+ /** Types reachable from request bodies. */
36
+ inputReachable: Set<Type>
37
+ /** Every type each resource touches, for output file grouping. */
38
+ byResource: Map<string, Set<Type>>
39
+ }
40
+
41
+ /**
42
+ * Walks every operation's payloads into read-side and input-side type sets.
43
+ *
44
+ * The read walk skips properties not visible in Lifecycle.Read: the server
45
+ * never returns them, so their types must not be dragged into read models.
46
+ * The input walk keeps every property because request trees are already
47
+ * visibility-projected by the spec (Create/Update request templates).
48
+ */
49
+ export function computeReachability(
50
+ program: Program,
51
+ groups: Iterable<readonly [string, Operation[]]>,
52
+ bodyOverrides: Map<string, Type>,
53
+ ): GoReachability {
54
+ const readReachable = new Set<Type>()
55
+ const inputReachable = new Set<Type>()
56
+ const byResource = new Map<string, Set<Type>>()
57
+
58
+ const makeWalker = (
59
+ resourceTypes: Set<Type>,
60
+ target: Set<Type>,
61
+ filterRead: boolean,
62
+ ) => {
63
+ // Each direction keeps its own visited set: a type reachable from both a
64
+ // request body and a response must land in both classification sets, not
65
+ // just whichever walk happened to run first.
66
+ const visited = new Set<Type>()
67
+ const visit = (type: Type | undefined): void => {
68
+ if (!type || visited.has(type)) {
69
+ return
70
+ }
71
+ visited.add(type)
72
+ resourceTypes.add(type)
73
+ target.add(type)
74
+
75
+ switch (type.kind) {
76
+ case 'Model':
77
+ if (type.baseModel) {
78
+ visit(type.baseModel)
79
+ }
80
+ if (type.indexer) {
81
+ visit(type.indexer.value)
82
+ }
83
+ for (const property of type.properties.values()) {
84
+ if (filterRead && !isVisible(program, property, Visibility.Read)) {
85
+ continue
86
+ }
87
+ visit(property.type)
88
+ }
89
+ break
90
+ case 'Union':
91
+ // Runtime-backed filter unions render as static runtime types
92
+ // (StringFilter, DateTimeFilter, ...); walking their variants would
93
+ // promote their anonymous object variants into dead declarations.
94
+ if (type.name && isRuntimeBackedTypeName(type.name, type.kind)) {
95
+ break
96
+ }
97
+ for (const variant of type.variants.values()) {
98
+ visit(variant.type)
99
+ }
100
+ break
101
+ case 'Tuple':
102
+ for (const value of type.values) {
103
+ visit(value)
104
+ }
105
+ break
106
+ case 'EnumMember':
107
+ visit(type.enum)
108
+ break
109
+ default:
110
+ break
111
+ }
112
+ }
113
+ return visit
114
+ }
115
+
116
+ for (const [resource, operations] of groups) {
117
+ const resourceTypes = new Set<Type>()
118
+ byResource.set(resource, resourceTypes)
119
+ const visitRead = makeWalker(resourceTypes, readReachable, true)
120
+ const visitInput = makeWalker(resourceTypes, inputReachable, false)
121
+
122
+ for (const operation of describeOperations(
123
+ program,
124
+ resource,
125
+ operations,
126
+ bodyOverrides,
127
+ )) {
128
+ for (const parameter of operation.pathParams) {
129
+ visitRead(parameter.type)
130
+ }
131
+ for (const parameter of operation.queryParams) {
132
+ if (parameter.name === 'filter' && parameter.type.kind === 'Model') {
133
+ // Deep-object filter models are rendered as query params structs by
134
+ // GoResource, not as JSON models; only their field types are models.
135
+ for (const property of parameter.type.properties.values()) {
136
+ visitRead(property.type)
137
+ }
138
+ } else {
139
+ visitRead(parameter.type)
140
+ }
141
+ }
142
+ visitRead(operation.response)
143
+ visitInput(operation.body)
144
+ }
145
+ }
146
+
147
+ return { readReachable, inputReachable, byResource }
148
+ }
149
+
150
+ /**
151
+ * Types whose input projection renders differently from their read projection,
152
+ * limited to types reachable from both directions (only those emit twice).
153
+ *
154
+ * Divergence causes: a defaulted property (optional in input), an optional
155
+ * collection (pointered in input so an explicitly empty collection survives
156
+ * omitempty), a property not visible in Lifecycle.Read (dropped from the read
157
+ * projection), or a reference to another divergent both-reachable type.
158
+ */
159
+ export function computeDivergentTypes(
160
+ program: Program,
161
+ readReachable: Set<Type>,
162
+ inputReachable: Set<Type>,
163
+ ): Set<Type> {
164
+ const typekit = $(program)
165
+ const divergent = new Set<Type>()
166
+ const memo = new Map<Type, boolean>()
167
+ const visiting = new Set<Type>()
168
+ const both = (type: Type): boolean =>
169
+ readReachable.has(type) && inputReachable.has(type)
170
+
171
+ const isCollection = (type: Type): boolean =>
172
+ type.kind === 'Model' && (typekit.array.is(type) || typekit.record.is(type))
173
+
174
+ // Whether a field or variant referencing this type renders a different Go
175
+ // type name in input context than in read context.
176
+ const referenceDiverges = (type: Type): boolean => {
177
+ switch (type.kind) {
178
+ case 'Model':
179
+ if (typekit.array.is(type) || typekit.record.is(type)) {
180
+ return type.indexer ? referenceDiverges(type.indexer.value) : false
181
+ }
182
+ return both(type) && diverges(type)
183
+ case 'Union':
184
+ if (optionalTypeName(program, type)) {
185
+ return both(type) && diverges(type)
186
+ }
187
+ return [...type.variants.values()].some((variant) =>
188
+ referenceDiverges(variant.type),
189
+ )
190
+ case 'Tuple':
191
+ return type.values.some(referenceDiverges)
192
+ default:
193
+ return false
194
+ }
195
+ }
196
+
197
+ const diverges = (type: Model | Union): boolean => {
198
+ const cached = memo.get(type)
199
+ if (cached !== undefined) {
200
+ return cached
201
+ }
202
+ if (visiting.has(type)) {
203
+ return false
204
+ }
205
+ visiting.add(type)
206
+
207
+ let result = false
208
+ if (type.kind === 'Model') {
209
+ for (const property of walkPropertiesInherited(type)) {
210
+ if (isHeader(program, property) || isStatusCode(program, property)) {
211
+ continue
212
+ }
213
+ if (
214
+ !isVisible(program, property, Visibility.Read) ||
215
+ // A default only changes the input rendering when the property is
216
+ // required: fieldShape keeps an already-optional property optional
217
+ // in both modes, so the projections stay byte-identical.
218
+ (property.defaultValue !== undefined && !property.optional) ||
219
+ (property.optional && isCollection(property.type)) ||
220
+ referenceDiverges(property.type)
221
+ ) {
222
+ result = true
223
+ break
224
+ }
225
+ }
226
+ if (!result && type.indexer) {
227
+ result = referenceDiverges(type.indexer.value)
228
+ }
229
+ } else {
230
+ result = [...type.variants.values()].some((variant) =>
231
+ referenceDiverges(variant.type),
232
+ )
233
+ }
234
+
235
+ visiting.delete(type)
236
+ memo.set(type, result)
237
+ if (result) {
238
+ divergent.add(type)
239
+ }
240
+ return result
241
+ }
242
+
243
+ for (const type of readReachable) {
244
+ if (
245
+ (type.kind === 'Model' || type.kind === 'Union') &&
246
+ inputReachable.has(type)
247
+ ) {
248
+ diverges(type)
249
+ }
250
+ }
251
+
252
+ // Only both-reachable types emit two projections; drop incidental members.
253
+ for (const type of [...divergent]) {
254
+ if (!both(type)) {
255
+ divergent.delete(type)
256
+ }
257
+ }
258
+
259
+ return divergent
260
+ }
261
+
262
+ /**
263
+ * Assigns deterministic Go type names to anonymous models reachable from the
264
+ * public surface, derived from the enclosing type plus the field (for example
265
+ * SubscriptionCreate.customer becomes SubscriptionCreateCustomer). Without
266
+ * promotion a required anonymous struct field cannot be populated in a
267
+ * composite literal without redeclaring the whole anonymous type.
268
+ */
269
+ export function promoteAnonymousModels(
270
+ program: Program,
271
+ types: Set<Type>,
272
+ ): Map<Type, string> {
273
+ const typekit = $(program)
274
+ const promoted = new Map<Type, string>()
275
+ const taken = new Map<string, Type>()
276
+ for (const type of types) {
277
+ const name = optionalTypeName(program, type)
278
+ if (name) {
279
+ taken.set(name, type)
280
+ }
281
+ }
282
+
283
+ const isAnonymousStruct = (type: Type): type is Model =>
284
+ type.kind === 'Model' &&
285
+ !typekit.array.is(type) &&
286
+ !typekit.record.is(type) &&
287
+ optionalTypeName(program, type) === undefined &&
288
+ promoted.get(type) === undefined
289
+
290
+ const claim = (type: Model, name: string): void => {
291
+ const existing = taken.get(name)
292
+ if (existing && existing !== type) {
293
+ throw new Error(
294
+ `typespec-go: promoted anonymous model name ${name} collides with an existing ${existing.kind}; add a @friendlyName to disambiguate`,
295
+ )
296
+ }
297
+ promoted.set(type, name)
298
+ taken.set(name, type)
299
+ visitModel(type, name)
300
+ }
301
+
302
+ const visitFieldType = (type: Type, parentName: string, field: string) => {
303
+ switch (type.kind) {
304
+ case 'Model':
305
+ if (typekit.array.is(type) || typekit.record.is(type)) {
306
+ if (type.indexer) {
307
+ visitFieldType(type.indexer.value, parentName, field)
308
+ }
309
+ return
310
+ }
311
+ if (isAnonymousStruct(type)) {
312
+ claim(type, `${parentName}${goExportedName(field)}`)
313
+ }
314
+ return
315
+ case 'Union':
316
+ for (const variant of type.variants.values()) {
317
+ visitFieldType(variant.type, parentName, field)
318
+ }
319
+ return
320
+ case 'Tuple':
321
+ for (const value of type.values) {
322
+ visitFieldType(value, parentName, field)
323
+ }
324
+ return
325
+ default:
326
+ return
327
+ }
328
+ }
329
+
330
+ const visitModel = (model: Model, name: string): void => {
331
+ for (const property of walkPropertiesInherited(model)) {
332
+ if (isHeader(program, property) || isStatusCode(program, property)) {
333
+ continue
334
+ }
335
+ visitFieldType(property.type, name, property.name)
336
+ }
337
+ if (model.indexer) {
338
+ visitFieldType(model.indexer.value, name, 'Item')
339
+ }
340
+ }
341
+
342
+ const named = [...types]
343
+ .flatMap((type) => {
344
+ if (type.kind !== 'Model' && type.kind !== 'Union') {
345
+ return []
346
+ }
347
+ if (
348
+ type.kind === 'Model' &&
349
+ (typekit.array.is(type) || typekit.record.is(type))
350
+ ) {
351
+ return []
352
+ }
353
+ const name = optionalTypeName(program, type)
354
+ return name ? [{ name, type }] : []
355
+ })
356
+ .sort((left, right) =>
357
+ left.name < right.name ? -1 : left.name > right.name ? 1 : 0,
358
+ )
359
+
360
+ for (const { name, type } of named) {
361
+ if (type.kind === 'Model') {
362
+ visitModel(type, name)
363
+ } else {
364
+ for (const variant of type.variants.values()) {
365
+ const field =
366
+ typeof variant.name === 'symbol'
367
+ ? String(variant.name.description ?? 'Variant')
368
+ : variant.name
369
+ visitFieldType(variant.type, name, field)
370
+ }
371
+ }
372
+ }
373
+
374
+ return promoted
375
+ }
376
+
377
+ /**
378
+ * Which projections each reachable type emits, and under what names.
379
+ *
380
+ * A type reachable only from requests emits only its input projection under
381
+ * the natural name; only from responses, only the read projection. A type
382
+ * reachable from both emits one declaration when the projections agree, and a
383
+ * read declaration plus a NameInput declaration when they diverge.
384
+ */
385
+ export function planDeclarations(
386
+ program: Program,
387
+ types: Set<Type>,
388
+ readReachable: Set<Type>,
389
+ inputReachable: Set<Type>,
390
+ divergent: Set<Type>,
391
+ ): Map<Type, GoDeclarationPlan[]> {
392
+ const typekit = $(program)
393
+ const plan = new Map<Type, GoDeclarationPlan[]>()
394
+
395
+ for (const type of types) {
396
+ if (type.kind !== 'Model' && type.kind !== 'Union') {
397
+ continue
398
+ }
399
+ if (
400
+ type.kind === 'Model' &&
401
+ (typekit.array.is(type) || typekit.record.is(type))
402
+ ) {
403
+ continue
404
+ }
405
+ const name = optionalTypeName(program, type)
406
+ if (!name || isRuntimeBackedTypeName(name, type.kind)) {
407
+ continue
408
+ }
409
+
410
+ const needsRead = readReachable.has(type)
411
+ const needsInput = inputReachable.has(type)
412
+ if (needsInput && !needsRead) {
413
+ plan.set(type, [{ name, mode: 'input' }])
414
+ } else if (needsRead && needsInput && divergent.has(type)) {
415
+ plan.set(type, [
416
+ { name, mode: 'read' },
417
+ { name: inputVariantName(name), mode: 'input' },
418
+ ])
419
+ } else {
420
+ plan.set(type, [{ name, mode: 'read' }])
421
+ }
422
+ }
423
+
424
+ return plan
425
+ }
426
+
427
+ const PROJECTION_PREFIXES = ['Create', 'Update', 'Upsert'] as const
428
+
429
+ interface PlannedDeclaration {
430
+ type: Type
431
+ declaration: GoDeclarationPlan
432
+ }
433
+
434
+ /**
435
+ * Collapses visibility-projection twins onto their canonical types.
436
+ *
437
+ * The spec's Create/Update request templates copy every nested model (and
438
+ * union) into a prefixed twin even when visibility filtering removes nothing,
439
+ * leaving byte-identical duplicates such as UpdateAddress next to Address.
440
+ * A declaration whose name is Create/Update/Upsert + the name of another
441
+ * emitted declaration (matched by emitted Go name, or by the source's declared
442
+ * TypeSpec name when the canonical emits under a @friendlyName), and whose
443
+ * rendered shape is identical, is dropped with every reference redirected to
444
+ * the canonical name — so read-modify-write flows need no type mapping.
445
+ *
446
+ * Matching is a recursive structural comparison rather than flat text
447
+ * equality: when two field references differ only because the candidate side
448
+ * points at another prefixed twin of the target side's type (UpdateRateCard-
449
+ * TaxConfig.code is UpdateResourceReference where RateCardTaxConfig.code is
450
+ * TaxCodeReference), that reference pair is matched recursively and committed
451
+ * as an additional alias when the enclosing declarations match. The outer
452
+ * loop runs to a fixpoint because twins reference other twins.
453
+ *
454
+ * Returns aliases keyed by the dropped declaration name; the corresponding
455
+ * plan entries are removed in place.
456
+ */
457
+ export function computeStructuralAliases(
458
+ program: Program,
459
+ plan: Map<Type, GoDeclarationPlan[]>,
460
+ readReachable: Set<Type>,
461
+ divergent: Set<Type>,
462
+ ): Map<string, string> {
463
+ const typekit = $(program)
464
+ const aliases = new Map<string, string>()
465
+
466
+ const declarationsByName = new Map<string, PlannedDeclaration>()
467
+ for (const [type, declarations] of plan) {
468
+ for (const declaration of declarations) {
469
+ declarationsByName.set(declaration.name, { type, declaration })
470
+ }
471
+ }
472
+
473
+ // FilterVisibility twins are named from the source's declared TypeSpec name,
474
+ // while the canonical type may emit under a @friendlyName (CreateCurrencyCode
475
+ // vs BillingCurrencyCode); index declared names so those still resolve. An
476
+ // ambiguous declared name (every ResourceReference<T> instantiation declares
477
+ // "ResourceReference") yields no top-level target, but such twins still
478
+ // collapse as reference pairs inside an enclosing declaration match, where
479
+ // the target instantiation is known from context.
480
+ const byDeclaredName = new Map<string, PlannedDeclaration | 'ambiguous'>()
481
+ for (const [type, declarations] of plan) {
482
+ const declared =
483
+ 'name' in type && typeof type.name === 'string' ? type.name : undefined
484
+ if (!declared) {
485
+ continue
486
+ }
487
+ const natural = declarations.find(
488
+ (declaration) => declaration.name === optionalTypeName(program, type),
489
+ )
490
+ if (!natural) {
491
+ continue
492
+ }
493
+ byDeclaredName.set(
494
+ declared,
495
+ byDeclaredName.has(declared)
496
+ ? 'ambiguous'
497
+ : { type, declaration: natural },
498
+ )
499
+ }
500
+
501
+ const resolveThrough = (
502
+ name: string,
503
+ pending: Map<string, string>,
504
+ ): string => {
505
+ let final = name
506
+ for (
507
+ let next = aliases.get(final) ?? pending.get(final);
508
+ next !== undefined;
509
+ next = aliases.get(final) ?? pending.get(final)
510
+ ) {
511
+ final = next
512
+ }
513
+ return final
514
+ }
515
+
516
+ const referenceName = (
517
+ type: Type,
518
+ mode: GoPayloadMode,
519
+ pending: Map<string, string>,
520
+ ): string | undefined => {
521
+ const name = optionalTypeName(program, type)
522
+ if (!name) {
523
+ return undefined
524
+ }
525
+ const runtime = type.kind === 'Union' ? runtimeFilterTypeName(name) : name
526
+ if (runtime !== name) {
527
+ return runtime
528
+ }
529
+ const base =
530
+ mode === 'input' && divergent.has(type) ? inputVariantName(name) : name
531
+ return resolveThrough(base, pending)
532
+ }
533
+
534
+ const projectionPrefixRest = (name: string): string | undefined => {
535
+ for (const prefix of PROJECTION_PREFIXES) {
536
+ if (
537
+ name.startsWith(prefix) &&
538
+ name.length > prefix.length &&
539
+ /[A-Z]/.test(name[prefix.length]!)
540
+ ) {
541
+ return name.slice(prefix.length)
542
+ }
543
+ }
544
+ return undefined
545
+ }
546
+
547
+ const scalarText = (type: Type): string | undefined => {
548
+ try {
549
+ return goType(program, type).text
550
+ } catch {
551
+ return undefined
552
+ }
553
+ }
554
+
555
+ // Whether referencing `left` in `leftMode` renders the same Go type
556
+ // expression as referencing `right` in `rightMode`, growing `pending` with
557
+ // the reference-pair aliases the match depends on.
558
+ const typesMatch = (
559
+ left: Type,
560
+ leftMode: GoPayloadMode,
561
+ right: Type,
562
+ rightMode: GoPayloadMode,
563
+ pending: Map<string, string>,
564
+ inProgress: Set<string>,
565
+ ): boolean => {
566
+ if (left.kind === 'Model' || right.kind === 'Model') {
567
+ if (left.kind !== 'Model' || right.kind !== 'Model') {
568
+ return false
569
+ }
570
+ const leftArray = typekit.array.is(left)
571
+ const rightArray = typekit.array.is(right)
572
+ const leftRecord = typekit.record.is(left)
573
+ const rightRecord = typekit.record.is(right)
574
+ if (leftArray !== rightArray || leftRecord !== rightRecord) {
575
+ return false
576
+ }
577
+ if (leftArray || leftRecord) {
578
+ return (
579
+ left.indexer !== undefined &&
580
+ right.indexer !== undefined &&
581
+ typesMatch(
582
+ left.indexer.value,
583
+ leftMode,
584
+ right.indexer.value,
585
+ rightMode,
586
+ pending,
587
+ inProgress,
588
+ )
589
+ )
590
+ }
591
+ return namedReferencesMatch(
592
+ left,
593
+ leftMode,
594
+ right,
595
+ rightMode,
596
+ pending,
597
+ inProgress,
598
+ )
599
+ }
600
+
601
+ if (left.kind === 'Union' || right.kind === 'Union') {
602
+ if (left.kind !== 'Union' || right.kind !== 'Union') {
603
+ return false
604
+ }
605
+ const leftNullable = nullableUnionElement(left)
606
+ const rightNullable = nullableUnionElement(right)
607
+ if ((leftNullable === undefined) !== (rightNullable === undefined)) {
608
+ return false
609
+ }
610
+ if (leftNullable && rightNullable) {
611
+ return typesMatch(
612
+ leftNullable,
613
+ leftMode,
614
+ rightNullable,
615
+ rightMode,
616
+ pending,
617
+ inProgress,
618
+ )
619
+ }
620
+ const leftOneOrMany = oneOrManyElement(program, left)
621
+ const rightOneOrMany = oneOrManyElement(program, right)
622
+ if ((leftOneOrMany === undefined) !== (rightOneOrMany === undefined)) {
623
+ return false
624
+ }
625
+ if (leftOneOrMany && rightOneOrMany) {
626
+ return typesMatch(
627
+ leftOneOrMany,
628
+ leftMode,
629
+ rightOneOrMany,
630
+ rightMode,
631
+ pending,
632
+ inProgress,
633
+ )
634
+ }
635
+ const leftNamed = optionalTypeName(program, left) !== undefined
636
+ const rightNamed = optionalTypeName(program, right) !== undefined
637
+ if (leftNamed && rightNamed) {
638
+ return namedReferencesMatch(
639
+ left,
640
+ leftMode,
641
+ right,
642
+ rightMode,
643
+ pending,
644
+ inProgress,
645
+ )
646
+ }
647
+ if (leftNamed || rightNamed) {
648
+ return false
649
+ }
650
+ // Anonymous unions render through goType's fallbacks; both sides must
651
+ // reduce to the same plain text (single concrete variant or string set).
652
+ const leftText = scalarText(left)
653
+ return leftText !== undefined && leftText === scalarText(right)
654
+ }
655
+
656
+ const leftText = scalarText(left)
657
+ return leftText !== undefined && leftText === scalarText(right)
658
+ }
659
+
660
+ const namedReferencesMatch = (
661
+ left: Type,
662
+ leftMode: GoPayloadMode,
663
+ right: Type,
664
+ rightMode: GoPayloadMode,
665
+ pending: Map<string, string>,
666
+ inProgress: Set<string>,
667
+ ): boolean => {
668
+ const leftName = referenceName(left, leftMode, pending)
669
+ const rightName = referenceName(right, rightMode, pending)
670
+ if (leftName === undefined || rightName === undefined) {
671
+ return false
672
+ }
673
+ if (leftName === rightName) {
674
+ return true
675
+ }
676
+
677
+ // Prefix tolerance: the candidate side may reference its own prefixed
678
+ // twin of the type the target side references.
679
+ const rest = projectionPrefixRest(leftName)
680
+ if (rest === undefined) {
681
+ return false
682
+ }
683
+ const rightDeclared =
684
+ 'name' in right && typeof right.name === 'string' ? right.name : undefined
685
+ if (rest !== rightName && rest !== rightDeclared) {
686
+ return false
687
+ }
688
+ const leftDeclaration = plan
689
+ .get(left)
690
+ ?.find((declaration) => declaration.name === leftName)
691
+ const rightDeclaration = plan
692
+ .get(right)
693
+ ?.find((declaration) => declaration.name === rightName)
694
+ if (!leftDeclaration || !rightDeclaration) {
695
+ return false
696
+ }
697
+ const key = `${leftName}->${rightName}`
698
+ if (inProgress.has(key)) {
699
+ return true
700
+ }
701
+ inProgress.add(key)
702
+ const matched = declarationsMatch(
703
+ left,
704
+ leftDeclaration.mode,
705
+ right,
706
+ rightDeclaration.mode,
707
+ pending,
708
+ inProgress,
709
+ )
710
+ inProgress.delete(key)
711
+ if (matched) {
712
+ pending.set(leftName, rightName)
713
+ }
714
+ return matched
715
+ }
716
+
717
+ const filteredProperties = (type: Model, mode: GoPayloadMode) =>
718
+ [...walkPropertiesInherited(type)].filter(
719
+ (property) =>
720
+ !isHeader(program, property) &&
721
+ !isStatusCode(program, property) &&
722
+ (mode === 'input' ||
723
+ !readReachable.has(type) ||
724
+ isVisible(program, property, Visibility.Read)),
725
+ )
726
+
727
+ const fieldShape = (
728
+ type: Model,
729
+ property: typeof type.properties extends Map<string, infer P> ? P : never,
730
+ mode: GoPayloadMode,
731
+ ) => {
732
+ const options = { mode: mode === 'input' ? ('input' as const) : undefined }
733
+ const mapped = goType(program, property.type, options)
734
+ const optional =
735
+ property.optional ||
736
+ (mode === 'input' && property.defaultValue !== undefined)
737
+ const pointerOptional =
738
+ optional && !mapped.jsonNullable && (!mapped.nilable || mode === 'input')
739
+ return {
740
+ name: goExportedName(property.name),
741
+ wireName: resolveEncodedName(program, property, 'application/json'),
742
+ optional,
743
+ pointerOptional,
744
+ }
745
+ }
746
+
747
+ // Whether the two planned declarations render identical Go code up to the
748
+ // declared type name, mirroring the GoModels render strategies.
749
+ const declarationsMatch = (
750
+ left: Type,
751
+ leftMode: GoPayloadMode,
752
+ right: Type,
753
+ rightMode: GoPayloadMode,
754
+ pending: Map<string, string>,
755
+ inProgress: Set<string>,
756
+ ): boolean => {
757
+ if (left.kind === 'Model' && right.kind === 'Model') {
758
+ const leftProperties = filteredProperties(left, leftMode)
759
+ const rightProperties = filteredProperties(right, rightMode)
760
+ if (leftProperties.length !== rightProperties.length) {
761
+ return false
762
+ }
763
+ for (let index = 0; index < leftProperties.length; index++) {
764
+ const leftProperty = leftProperties[index]!
765
+ const rightProperty = rightProperties[index]!
766
+ let leftShape
767
+ let rightShape
768
+ try {
769
+ leftShape = fieldShape(left, leftProperty, leftMode)
770
+ rightShape = fieldShape(right, rightProperty, rightMode)
771
+ } catch {
772
+ return false
773
+ }
774
+ if (
775
+ leftShape.name !== rightShape.name ||
776
+ leftShape.wireName !== rightShape.wireName ||
777
+ leftShape.optional !== rightShape.optional ||
778
+ leftShape.pointerOptional !== rightShape.pointerOptional ||
779
+ !typesMatch(
780
+ leftProperty.type,
781
+ leftMode,
782
+ rightProperty.type,
783
+ rightMode,
784
+ pending,
785
+ inProgress,
786
+ )
787
+ ) {
788
+ return false
789
+ }
790
+ }
791
+ return true
792
+ }
793
+
794
+ if (left.kind === 'Union' && right.kind === 'Union') {
795
+ return unionDeclarationsMatch(
796
+ left,
797
+ leftMode,
798
+ right,
799
+ rightMode,
800
+ pending,
801
+ inProgress,
802
+ )
803
+ }
804
+
805
+ return false
806
+ }
807
+
808
+ const unionRenderStrategy = (
809
+ union: Union,
810
+ ): 'nullable' | 'strenum' | 'stringlike' | 'tagged' | 'alias' | 'invalid' => {
811
+ if (nullableUnionElement(union)) {
812
+ return 'nullable'
813
+ }
814
+ const variants = [...union.variants.values()]
815
+ if (
816
+ variants.length > 0 &&
817
+ variants.every((variant) => variant.type.kind === 'String')
818
+ ) {
819
+ return 'strenum'
820
+ }
821
+ if (
822
+ variants.length > 0 &&
823
+ variants.every((variant) => isStringLike(program, variant.type))
824
+ ) {
825
+ return 'stringlike'
826
+ }
827
+ if (
828
+ variants.length > 0 &&
829
+ variants.every((variant) => variant.type.kind === 'Model')
830
+ ) {
831
+ return 'tagged'
832
+ }
833
+ const concrete = variants.filter(
834
+ (variant) => variant.type.kind !== 'Intrinsic',
835
+ )
836
+ if (concrete.length === 1) {
837
+ return 'alias'
838
+ }
839
+ return concrete.length > 1 ? 'tagged' : 'invalid'
840
+ }
841
+
842
+ const unionDeclarationsMatch = (
843
+ left: Union,
844
+ leftMode: GoPayloadMode,
845
+ right: Union,
846
+ rightMode: GoPayloadMode,
847
+ pending: Map<string, string>,
848
+ inProgress: Set<string>,
849
+ ): boolean => {
850
+ const strategy = unionRenderStrategy(left)
851
+ if (strategy !== unionRenderStrategy(right) || strategy === 'invalid') {
852
+ return false
853
+ }
854
+
855
+ const leftVariants = [...left.variants.values()]
856
+ const rightVariants = [...right.variants.values()]
857
+
858
+ switch (strategy) {
859
+ case 'nullable':
860
+ return typesMatch(
861
+ nullableUnionElement(left)!,
862
+ leftMode,
863
+ nullableUnionElement(right)!,
864
+ rightMode,
865
+ pending,
866
+ inProgress,
867
+ )
868
+ case 'strenum': {
869
+ if (leftVariants.length !== rightVariants.length) {
870
+ return false
871
+ }
872
+ return leftVariants.every((variant, index) => {
873
+ const other = rightVariants[index]!
874
+ return (
875
+ variant.type.kind === 'String' &&
876
+ other.type.kind === 'String' &&
877
+ goExportedName(String(variant.name)) ===
878
+ goExportedName(String(other.name)) &&
879
+ variant.type.value === other.type.value
880
+ )
881
+ })
882
+ }
883
+ case 'stringlike':
884
+ return true
885
+ case 'alias': {
886
+ const leftConcrete = leftVariants.filter(
887
+ (variant) => variant.type.kind !== 'Intrinsic',
888
+ )[0]!
889
+ const rightConcrete = rightVariants.filter(
890
+ (variant) => variant.type.kind !== 'Intrinsic',
891
+ )[0]!
892
+ return typesMatch(
893
+ leftConcrete.type,
894
+ leftMode,
895
+ rightConcrete.type,
896
+ rightMode,
897
+ pending,
898
+ inProgress,
899
+ )
900
+ }
901
+ case 'tagged': {
902
+ const leftConcrete = leftVariants.filter(
903
+ (variant) => variant.type.kind !== 'Intrinsic',
904
+ )
905
+ const rightConcrete = rightVariants.filter(
906
+ (variant) => variant.type.kind !== 'Intrinsic',
907
+ )
908
+ if (leftConcrete.length !== rightConcrete.length) {
909
+ return false
910
+ }
911
+ const leftDiscriminator = discriminatorProperty(
912
+ program,
913
+ left,
914
+ leftConcrete.flatMap((variant) =>
915
+ variant.type.kind === 'Model' ? [variant.type] : [],
916
+ ),
917
+ )
918
+ const rightDiscriminator = discriminatorProperty(
919
+ program,
920
+ right,
921
+ rightConcrete.flatMap((variant) =>
922
+ variant.type.kind === 'Model' ? [variant.type] : [],
923
+ ),
924
+ )
925
+ if (leftDiscriminator?.wireName !== rightDiscriminator?.wireName) {
926
+ return false
927
+ }
928
+ return leftConcrete.every((variant, index) => {
929
+ const other = rightConcrete[index]!
930
+ if (variant.type.kind !== other.type.kind) {
931
+ return false
932
+ }
933
+ if (variant.type.kind === 'Model' && other.type.kind === 'Model') {
934
+ if (
935
+ discriminatorLiteral(variant.type, leftDiscriminator) !==
936
+ discriminatorLiteral(other.type, rightDiscriminator)
937
+ ) {
938
+ return false
939
+ }
940
+ } else if (
941
+ variantAccessorName(program, '', variant) !==
942
+ variantAccessorName(program, '', other)
943
+ ) {
944
+ return false
945
+ }
946
+ return typesMatch(
947
+ variant.type,
948
+ leftMode,
949
+ other.type,
950
+ rightMode,
951
+ pending,
952
+ inProgress,
953
+ )
954
+ })
955
+ }
956
+ }
957
+ }
958
+
959
+ const sortedEntries = (): PlannedDeclaration[] =>
960
+ [...plan]
961
+ .flatMap(([type, declarations]) =>
962
+ declarations.map((declaration) => ({ type, declaration })),
963
+ )
964
+ .sort((leftEntry, rightEntry) =>
965
+ leftEntry.declaration.name < rightEntry.declaration.name
966
+ ? -1
967
+ : leftEntry.declaration.name > rightEntry.declaration.name
968
+ ? 1
969
+ : 0,
970
+ )
971
+
972
+ for (let changed = true; changed; ) {
973
+ changed = false
974
+ for (const { type, declaration } of sortedEntries()) {
975
+ if (aliases.has(declaration.name)) {
976
+ continue
977
+ }
978
+ const rest = projectionPrefixRest(declaration.name)
979
+ if (rest === undefined) {
980
+ continue
981
+ }
982
+ const declaredMatch = byDeclaredName.get(rest)
983
+ const target =
984
+ declarationsByName.get(resolveThrough(rest, new Map())) ??
985
+ (declaredMatch === 'ambiguous' ? undefined : declaredMatch)
986
+ if (!target || target.type === type) {
987
+ continue
988
+ }
989
+ const targetName = resolveThrough(target.declaration.name, new Map())
990
+ if (aliases.has(target.declaration.name)) {
991
+ // The canonical itself collapsed; follow it to its final home.
992
+ const followed = declarationsByName.get(targetName)
993
+ if (!followed || followed.type === type) {
994
+ continue
995
+ }
996
+ }
997
+
998
+ const pending = new Map<string, string>()
999
+ if (
1000
+ declarationsMatch(
1001
+ type,
1002
+ declaration.mode,
1003
+ target.type,
1004
+ target.declaration.mode,
1005
+ pending,
1006
+ new Set([`${declaration.name}->${targetName}`]),
1007
+ )
1008
+ ) {
1009
+ aliases.set(declaration.name, targetName)
1010
+ for (const [from, to] of pending) {
1011
+ if (!aliases.has(from)) {
1012
+ aliases.set(from, to)
1013
+ }
1014
+ }
1015
+ changed = true
1016
+ }
1017
+ }
1018
+ }
1019
+
1020
+ // Flatten chains and drop the collapsed declarations from the plan.
1021
+ for (const [name] of aliases) {
1022
+ aliases.set(name, resolveThrough(name, new Map()))
1023
+ }
1024
+ for (const [type, declarations] of plan) {
1025
+ const kept = declarations.filter(
1026
+ (declaration) => !aliases.has(declaration.name),
1027
+ )
1028
+ if (kept.length === declarations.length) {
1029
+ continue
1030
+ }
1031
+ if (kept.length === 0) {
1032
+ plan.delete(type)
1033
+ } else {
1034
+ plan.set(type, kept)
1035
+ }
1036
+ }
1037
+
1038
+ return aliases
1039
+ }
api/spec/packages/typespec-go/src/readme.ts ADDED
@@ -0,0 +1,371 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Operation, Program, Type } from '@typespec/compiler'
2
+ import { $ } from '@typespec/compiler/typekit'
3
+ import { goExportedName } from './go-types.js'
4
+ import { namespaceNames } from './grouping.js'
5
+ import { describeOperations, type GoOperation } from './operations.js'
6
+
7
+ export interface ReadmeService {
8
+ root: string
9
+ nestPath: string[]
10
+ operations: Operation[]
11
+ }
12
+
13
+ interface ResourceSection {
14
+ root: string
15
+ operations: Array<{ service: ReadmeService; operation: GoOperation }>
16
+ }
17
+
18
+ const HEADINGS = {
19
+ toc: 'Table of Contents',
20
+ install: 'Installation',
21
+ init: 'Initialization',
22
+ usage: 'Usage',
23
+ resources: 'Available Resources and Operations',
24
+ errors: 'Error Handling',
25
+ pagination: 'Pagination and Streaming',
26
+ } as const
27
+
28
+ /** GitHub's heading-to-anchor slug: lowercase, strip punctuation, spaces to
29
+ * hyphens. The table of contents is built with this same function so its links
30
+ * always resolve to the headings it points at. */
31
+ function slug(heading: string): string {
32
+ return heading
33
+ .toLowerCase()
34
+ .replace(/[^\w\s-]/g, '')
35
+ .trim()
36
+ .replace(/\s+/g, '-')
37
+ }
38
+
39
+ function tocEntry(heading: string, depth: number): string {
40
+ const indent = ' '.repeat(depth)
41
+ return `${indent}- [${heading}](#${slug(heading)})`
42
+ }
43
+
44
+ function header(note?: string): string {
45
+ const lines = [
46
+ '# OpenMeter Go SDK',
47
+ '',
48
+ 'Go client for the OpenMeter API — usage metering and billing for',
49
+ 'AI and DevTool companies. This package is generated from the OpenMeter',
50
+ 'TypeSpec definitions and ships typed request and response models.',
51
+ ]
52
+ if (note) {
53
+ lines.push('', note.trim())
54
+ }
55
+ return lines.join('\n')
56
+ }
57
+
58
+ function tableOfContents(resources: ResourceSection[]): string {
59
+ const lines = [`## ${HEADINGS.toc}`, '']
60
+ lines.push(tocEntry(HEADINGS.install, 0))
61
+ lines.push(tocEntry(HEADINGS.init, 0))
62
+ lines.push(tocEntry(HEADINGS.usage, 0))
63
+ lines.push(tocEntry(HEADINGS.resources, 0))
64
+ for (const resource of resources) {
65
+ lines.push(tocEntry(namespaceNames(resource.root).class, 1))
66
+ }
67
+ lines.push(tocEntry(HEADINGS.errors, 0))
68
+ lines.push(tocEntry(HEADINGS.pagination, 0))
69
+ return lines.join('\n')
70
+ }
71
+
72
+ function installation(modulePath: string): string {
73
+ return [
74
+ `## ${HEADINGS.install}`,
75
+ '',
76
+ '```bash',
77
+ `go get ${modulePath}`,
78
+ '```',
79
+ ].join('\n')
80
+ }
81
+
82
+ function initialization(modulePath: string, packageName: string): string {
83
+ return [
84
+ `## ${HEADINGS.init}`,
85
+ '',
86
+ 'Create a client with a base URL and an API key. The API key is sent as a',
87
+ '`Bearer` token on every request.',
88
+ '',
89
+ '```go',
90
+ 'package main',
91
+ '',
92
+ 'import (',
93
+ '\t"log"',
94
+ '\t"os"',
95
+ '',
96
+ `\t"${modulePath}"`,
97
+ ')',
98
+ '',
99
+ 'func main() {',
100
+ `\tom, err := ${packageName}.New(`,
101
+ '\t\t"https://openmeter.cloud/api/v3",',
102
+ `\t\t${packageName}.WithToken(os.Getenv("OPENMETER_API_KEY")),`,
103
+ '\t)',
104
+ '\tif err != nil {',
105
+ '\t\tlog.Fatal(err)',
106
+ '\t}',
107
+ '',
108
+ '\t_ = om',
109
+ '}',
110
+ '```',
111
+ '',
112
+ 'For region-specific deployments, pass the concrete API base URL for that',
113
+ 'region to `New`.',
114
+ ].join('\n')
115
+ }
116
+
117
+ function usage(modulePath: string, packageName: string): string {
118
+ return [
119
+ `## ${HEADINGS.usage}`,
120
+ '',
121
+ 'Every operation is reachable through a namespaced service on the client and',
122
+ 'returns a typed response plus an `error`.',
123
+ '',
124
+ '```go',
125
+ 'package main',
126
+ '',
127
+ 'import (',
128
+ '\t"context"',
129
+ '\t"log"',
130
+ '\t"os"',
131
+ '',
132
+ `\t"${modulePath}"`,
133
+ ')',
134
+ '',
135
+ 'func main() {',
136
+ `\tom, err := ${packageName}.New(`,
137
+ '\t\t"https://openmeter.cloud/api/v3",',
138
+ `\t\t${packageName}.WithToken(os.Getenv("OPENMETER_API_KEY")),`,
139
+ '\t)',
140
+ '\tif err != nil {',
141
+ '\t\tlog.Fatal(err)',
142
+ '\t}',
143
+ '',
144
+ '\tctx := context.Background()',
145
+ `\tmeter, err := om.Meters.Create(ctx, ${packageName}.CreateMeterRequest{`,
146
+ '\t\tName: "Tokens",',
147
+ '\t\tKey: "tokens",',
148
+ `\t\tAggregation: ${packageName}.MeterAggregationSum,`,
149
+ '\t\tEventType: "request",',
150
+ `\t\tValueProperty: ${packageName}.String("$.tokens"),`,
151
+ '\t})',
152
+ '\tif err != nil {',
153
+ '\t\tlog.Fatal(err)',
154
+ '\t}',
155
+ '',
156
+ `\tmeters, err := om.Meters.List(ctx, ${packageName}.MeterListParams{})`,
157
+ '\tif err != nil {',
158
+ '\t\tlog.Fatal(err)',
159
+ '\t}',
160
+ '',
161
+ '\t_, _ = meter, meters',
162
+ '}',
163
+ '```',
164
+ '',
165
+ 'Operation arguments follow the generated method signature: path parameters',
166
+ 'come first, then a typed request body when present, then typed query params',
167
+ 'when present.',
168
+ ].join('\n')
169
+ }
170
+
171
+ function callPath(service: ReadmeService, methodName: string): string {
172
+ return ['om', service.root, ...service.nestPath, methodName].join('.')
173
+ }
174
+
175
+ // Mirrors GoResource's isTextResponse: text responses grow a Stream method
176
+ // variant alongside the byte-returning method.
177
+ function isTextResponse(operation: GoOperation): boolean {
178
+ return operation.responseContentType?.startsWith('text/') ?? false
179
+ }
180
+
181
+ function summaryCell(program: Program, op: GoOperation): string {
182
+ const summary = $(program).type.getDoc(op.operation)
183
+ if (!summary) {
184
+ return ''
185
+ }
186
+
187
+ return summary
188
+ .trim()
189
+ .replace(/\s+/g, ' ')
190
+ .replace(/\\/g, '\\\\')
191
+ .replace(/\|/g, '\\|')
192
+ }
193
+
194
+ function operationsTable(
195
+ program: Program,
196
+ operations: ResourceSection['operations'],
197
+ ): string {
198
+ const header = ['| Method | HTTP | Description |', '| --- | --- | --- |']
199
+ const rows = operations.flatMap(({ service, operation }) => {
200
+ const call = `\`${callPath(service, operation.methodName)}\``
201
+ const http = `\`${operation.verb.toUpperCase()} ${operation.path}\``
202
+ const row = `| ${call} | ${http} | ${summaryCell(program, operation)} |`
203
+ if (!isTextResponse(operation)) {
204
+ return [row]
205
+ }
206
+ const streamName = `${goExportedName(operation.methodName)}Stream`
207
+ const streamCall = `\`${callPath(service, streamName)}\``
208
+ return [
209
+ row,
210
+ `| ${streamCall} | ${http} | Streaming variant of \`${operation.methodName}\` returning an \`io.ReadCloser\`. |`,
211
+ ]
212
+ })
213
+ return [...header, ...rows].join('\n')
214
+ }
215
+
216
+ function resourcesSection(
217
+ program: Program,
218
+ resources: ResourceSection[],
219
+ ): string {
220
+ const blocks = [
221
+ `## ${HEADINGS.resources}`,
222
+ '',
223
+ 'Operations are grouped by resource and exposed as services on the client.',
224
+ 'The full call path, HTTP route, and a short description are listed below.',
225
+ ]
226
+
227
+ for (const resource of resources) {
228
+ blocks.push(
229
+ '',
230
+ `### ${namespaceNames(resource.root).class}`,
231
+ '',
232
+ operationsTable(program, resource.operations),
233
+ )
234
+ }
235
+
236
+ return blocks.join('\n')
237
+ }
238
+
239
+ function errorHandling(modulePath: string, packageName: string): string {
240
+ return [
241
+ `## ${HEADINGS.errors}`,
242
+ '',
243
+ 'A non-2xx response returns an `*APIError` carrying the problem-details',
244
+ 'fields (`StatusCode`, `Status`, `Type`, `Title`, `Detail`, `Instance`) from',
245
+ 'the response where available. Client-side validation errors such as an empty',
246
+ 'path ID are returned before any HTTP request is made.',
247
+ '',
248
+ '```go',
249
+ 'package main',
250
+ '',
251
+ 'import (',
252
+ '\t"context"',
253
+ '\t"errors"',
254
+ '\t"log"',
255
+ '',
256
+ `\t"${modulePath}"`,
257
+ ')',
258
+ '',
259
+ 'func main() {',
260
+ `\tom, err := ${packageName}.New("https://openmeter.cloud/api/v3", ${packageName}.WithToken("om_..."))`,
261
+ '\tif err != nil {',
262
+ '\t\tlog.Fatal(err)',
263
+ '\t}',
264
+ '',
265
+ '\t_, err = om.Meters.Get(context.Background(), "unknown")',
266
+ '\tif err != nil {',
267
+ `\t\tvar apiErr *${packageName}.APIError`,
268
+ '\t\tif errors.As(err, &apiErr) {',
269
+ '\t\t\tlog.Printf("%d %s %s", apiErr.StatusCode, apiErr.Title, apiErr.Type)',
270
+ '\t\t\treturn',
271
+ '\t\t}',
272
+ '\t\tlog.Fatal(err)',
273
+ '\t}',
274
+ '}',
275
+ '```',
276
+ ].join('\n')
277
+ }
278
+
279
+ function paginationAndStreaming(packageName: string): string {
280
+ return [
281
+ `## ${HEADINGS.pagination}`,
282
+ '',
283
+ 'Paginated list operations also emit `ListAll` helpers that return',
284
+ '`iter.Seq2[T, error]`. Text responses such as meter CSV export emit a byte',
285
+ 'returning method and a `Stream` variant for callers that want an',
286
+ '`io.ReadCloser`.',
287
+ '',
288
+ 'Cursor-paginated responses report their position as `Next` and `Previous`',
289
+ 'on `CursorMetaPage`. Both are opaque cursor tokens: pass them back verbatim',
290
+ 'as the `page[after]` / `page[before]` query parameters',
291
+ '(`CursorPageParams.After` / `CursorPageParams.Before`); do not parse or',
292
+ 'construct them.',
293
+ '',
294
+ 'Iterating with `Before` set walks pages backward while the items within',
295
+ 'each page stay in forward order, so the resulting stream is not globally',
296
+ 'sorted.',
297
+ '',
298
+ '```go',
299
+ `for meter, err := range om.Meters.ListAll(ctx, ${packageName}.MeterListParams{}) {`,
300
+ '\tif err != nil {',
301
+ '\t\tlog.Fatal(err)',
302
+ '\t}',
303
+ '\tlog.Println(meter.Key)',
304
+ '}',
305
+ '',
306
+ `stream, err := om.Meters.QueryCSVStream(ctx, "meter-id", ${packageName}.MeterQueryRequest{})`,
307
+ 'if err != nil {',
308
+ '\tlog.Fatal(err)',
309
+ '}',
310
+ 'defer stream.Close()',
311
+ '```',
312
+ ].join('\n')
313
+ }
314
+
315
+ function resourceSections(
316
+ program: Program,
317
+ services: ReadmeService[],
318
+ bodyOverrides: Map<string, Type>,
319
+ ): ResourceSection[] {
320
+ const byRoot = new Map<string, ResourceSection>()
321
+ for (const service of services) {
322
+ const section = byRoot.get(service.root) ?? {
323
+ root: service.root,
324
+ operations: [],
325
+ }
326
+ if (!byRoot.has(service.root)) {
327
+ byRoot.set(service.root, section)
328
+ }
329
+
330
+ for (const operation of describeOperations(
331
+ program,
332
+ service.root,
333
+ service.operations,
334
+ bodyOverrides,
335
+ service.nestPath,
336
+ )) {
337
+ section.operations.push({ service, operation })
338
+ }
339
+ }
340
+
341
+ return [...byRoot.values()].filter(
342
+ (resource) => resource.operations.length > 0,
343
+ )
344
+ }
345
+
346
+ /** The package README, built from the same grouped operations the Go SDK files
347
+ * are generated from, so the documented call paths and routes always match the
348
+ * emitted client. */
349
+ export function readmeFile(
350
+ program: Program,
351
+ modulePath: string,
352
+ packageName: string,
353
+ services: ReadmeService[],
354
+ bodyOverrides: Map<string, Type>,
355
+ note?: string,
356
+ ): string {
357
+ const resources = resourceSections(program, services, bodyOverrides)
358
+
359
+ return (
360
+ [
361
+ header(note),
362
+ tableOfContents(resources),
363
+ installation(modulePath),
364
+ initialization(modulePath, packageName),
365
+ usage(modulePath, packageName),
366
+ resourcesSection(program, resources),
367
+ errorHandling(modulePath, packageName),
368
+ paginationAndStreaming(packageName),
369
+ ].join('\n\n') + '\n'
370
+ )
371
+ }
api/spec/packages/typespec-go/src/runtime-symbols.ts ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Type } from '@typespec/compiler'
2
+
3
+ // Exported names owned by the static runtime templates and fixed client
4
+ // scaffolding. TypeSpec-generated declarations share Go's package-level
5
+ // namespace with these symbols, so any accidental overlap must stop emission
6
+ // before the generated SDK reaches go build.
7
+ export const RESERVED_GO_SYMBOL_NAMES = new Set([
8
+ 'APIError',
9
+ 'AsAPIError',
10
+ 'Bool',
11
+ 'BooleanFilter',
12
+ 'Client',
13
+ 'CursorPageParams',
14
+ 'DateTimeFilter',
15
+ 'DecodeAPIError',
16
+ 'ErrEmptyID',
17
+ 'Int',
18
+ 'Many',
19
+ 'New',
20
+ 'Null',
21
+ 'Nullable',
22
+ 'NullableValue',
23
+ 'Numeric',
24
+ 'NumericFilter',
25
+ 'One',
26
+ 'OneOrMany',
27
+ 'Option',
28
+ 'PageMeta',
29
+ 'PageParams',
30
+ 'PaginatedMeta',
31
+ 'Ptr',
32
+ 'Sort',
33
+ 'SortOrder',
34
+ 'SortOrderAsc',
35
+ 'SortOrderDesc',
36
+ 'String',
37
+ 'StringExactFilter',
38
+ 'StringFilter',
39
+ 'Time',
40
+ 'Version',
41
+ 'WithHTTPClient',
42
+ 'WithToken',
43
+ 'WithUserAgent',
44
+ ])
45
+
46
+ // TypeSpec constructs that intentionally map to runtime-owned SDK shapes rather
47
+ // than generated model declarations.
48
+ const RUNTIME_BACKED_TYPE_NAMES = new Set([
49
+ 'Numeric',
50
+ 'PageMeta',
51
+ 'PageParams',
52
+ 'PaginatedMeta',
53
+ 'SortQuery',
54
+ 'StringFilter',
55
+ 'StringFieldFilter',
56
+ 'StringFieldFilterExact',
57
+ ])
58
+
59
+ export function isRuntimeBackedTypeName(
60
+ name: string,
61
+ kind: Type['kind'],
62
+ ): boolean {
63
+ if (name === 'String') {
64
+ return kind === 'Scalar'
65
+ }
66
+
67
+ return (
68
+ RUNTIME_BACKED_TYPE_NAMES.has(name) ||
69
+ name.endsWith('FieldFilter') ||
70
+ name.endsWith('FieldFilterExact')
71
+ )
72
+ }
73
+
74
+ export function conflictsWithReservedGoSymbol(
75
+ name: string,
76
+ kind: Type['kind'],
77
+ ): boolean {
78
+ return (
79
+ RESERVED_GO_SYMBOL_NAMES.has(name) && !isRuntimeBackedTypeName(name, kind)
80
+ )
81
+ }
api/spec/packages/typespec-go/src/runtime-templates.ts ADDED
@@ -0,0 +1,868 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Static Go SDK runtime files emitted into the generated SDK.
2
+ //
3
+ // Keep these as emitter-owned TypeScript templates rather than files in a
4
+ // runtime/ Go package. That avoids making the emitter source tree look like a
5
+ // standalone Go module while still keeping the generated runtime reviewable.
6
+
7
+ export const RUNTIME_TEMPLATES: Record<string, string> = {
8
+ 'errors.go': `package openmeter
9
+
10
+ import (
11
+ "encoding/json"
12
+ "errors"
13
+ "fmt"
14
+ )
15
+
16
+ // ErrEmptyID is returned by operations that target a single resource when the
17
+ // resource ID is empty. It is caught before any request is made so an omitted
18
+ // ID surfaces as a clear client-side error rather than an ambiguous server
19
+ // response. Match it with errors.Is.
20
+ var ErrEmptyID = errors.New("openmeter: resource ID must not be empty")
21
+
22
+ // APIError is returned for any non-2xx API response. It mirrors the API's
23
+ // RFC 7807-style problem body. When the body cannot be parsed as such, Title is
24
+ // left empty and RawBody carries the undecoded payload.
25
+ type APIError struct {
26
+ // StatusCode is the HTTP status code of the response.
27
+ StatusCode int \`json:"-"\`
28
+
29
+ // Status is the status code echoed in the problem body (usually equal to
30
+ // StatusCode).
31
+ Status int \`json:"status"\`
32
+ // Title is a short, stable, human-readable summary of the problem.
33
+ Title string \`json:"title"\`
34
+ // Type is an optional machine-readable error type.
35
+ Type string \`json:"type,omitempty"\`
36
+ // Detail is a human-readable explanation specific to this occurrence.
37
+ Detail string \`json:"detail"\`
38
+ // Instance carries the correlation ID, formatted as kong:trace:<id>.
39
+ Instance string \`json:"instance"\`
40
+
41
+ // RawBody is the undecoded response body, always populated.
42
+ RawBody []byte \`json:"-"\`
43
+ }
44
+
45
+ func newAPIError(statusCode int, body []byte) *APIError {
46
+ e := &APIError{StatusCode: statusCode, RawBody: body}
47
+ // Best-effort decode; a non-conforming body still yields a useful error via
48
+ // StatusCode and RawBody.
49
+ _ = json.Unmarshal(body, e)
50
+ return e
51
+ }
52
+
53
+ // AsAPIError returns the APIError inside err, when err came from an API response.
54
+ func AsAPIError(err error) (*APIError, bool) {
55
+ var apiErr *APIError
56
+ if errors.As(err, &apiErr) {
57
+ return apiErr, true
58
+ }
59
+ return nil, false
60
+ }
61
+
62
+ // Decode decodes the original error response body into out.
63
+ func (e *APIError) Decode(out any) error {
64
+ return json.Unmarshal(e.RawBody, out)
65
+ }
66
+
67
+ // DecodeAPIError decodes an API error body into T. The returned boolean is false
68
+ // when err is not an APIError.
69
+ func DecodeAPIError[T any](err error) (T, bool, error) {
70
+ var zero T
71
+ apiErr, ok := AsAPIError(err)
72
+ if !ok {
73
+ return zero, false, nil
74
+ }
75
+
76
+ var out T
77
+ if err := apiErr.Decode(&out); err != nil {
78
+ return zero, true, err
79
+ }
80
+ return out, true, nil
81
+ }
82
+
83
+ func (e *APIError) Error() string {
84
+ switch {
85
+ case e.Title != "" && e.Detail != "":
86
+ return fmt.Sprintf("openmeter: %d %s: %s", e.StatusCode, e.Title, e.Detail)
87
+ case e.Title != "":
88
+ return fmt.Sprintf("openmeter: %d %s", e.StatusCode, e.Title)
89
+ default:
90
+ // No RFC 7807 fields parsed (e.g. a proxy returned an HTML error page).
91
+ // Inline the raw body for diagnostics but bound it so a large payload
92
+ // can't blow up log lines; RawBody still holds the full response.
93
+ const maxInline = 512
94
+ body := e.RawBody
95
+ suffix := ""
96
+ if len(body) > maxInline {
97
+ body = body[:maxInline]
98
+ suffix = "… (truncated)"
99
+ }
100
+ return fmt.Sprintf("openmeter: unexpected status %d: %s%s", e.StatusCode, string(body), suffix)
101
+ }
102
+ }
103
+ `,
104
+ 'filters.go': `package openmeter
105
+
106
+ import "time"
107
+
108
+ // StringFilter expresses the comparison operators accepted for string fields.
109
+ type StringFilter struct {
110
+ Eq *string
111
+ Neq *string
112
+ Gt *string
113
+ Gte *string
114
+ Lt *string
115
+ Lte *string
116
+ Contains *string
117
+ Oeq []string
118
+ Ocontains []string
119
+ Exists *bool
120
+ }
121
+
122
+ // StringExactFilter expresses exact comparisons for strings and ULIDs.
123
+ type StringExactFilter struct {
124
+ Eq *string
125
+ Neq *string
126
+ Oeq []string
127
+ }
128
+
129
+ // DateTimeFilter expresses comparisons against RFC 3339 timestamps.
130
+ type DateTimeFilter struct {
131
+ Eq *time.Time
132
+ Gt *time.Time
133
+ Gte *time.Time
134
+ Lt *time.Time
135
+ Lte *time.Time
136
+ }
137
+
138
+ // NumericFilter expresses numeric comparison operators.
139
+ type NumericFilter struct {
140
+ Eq *float64
141
+ Neq *float64
142
+ Gt *float64
143
+ Gte *float64
144
+ Lt *float64
145
+ Lte *float64
146
+ Oeq []float64
147
+ }
148
+
149
+ // BooleanFilter expresses equality for a boolean field.
150
+ type BooleanFilter struct {
151
+ Eq *bool
152
+ }
153
+ `,
154
+ // {{MODULE_PATH}} is interpolated from the required module-path option.
155
+ // {{GO_VERSION}} comes from the go-version option, defaulting to 1.23 (the
156
+ // generated code's actual floor, needed by the iter package); a repo can
157
+ // raise it to a higher consumer floor, e.g. when preserved *_test.go files
158
+ // need newer stdlib APIs. The single-entry require stays unparenthesized so
159
+ // 'go mod tidy -diff' is a no-op on the emitted file.
160
+ 'go.mod': `module {{MODULE_PATH}}
161
+
162
+ go {{GO_VERSION}}
163
+
164
+ require github.com/oapi-codegen/nullable v1.2.0
165
+ `,
166
+ 'go.sum': `github.com/oapi-codegen/nullable v1.2.0 h1:VflFkDW980KhBPiFF7nWSyjg+r4Obqj8lXipV0UkP5w=
167
+ github.com/oapi-codegen/nullable v1.2.0/go.mod h1:KUZ3vUzkmEKY90ksAmit2+5juDIhIZhfDl+0PwOQlFY=
168
+ `,
169
+ 'nullable.go': `package openmeter
170
+
171
+ import "github.com/oapi-codegen/nullable"
172
+
173
+ // Nullable represents a JSON field with three states: unspecified, explicit
174
+ // null, or a concrete value. Optional nullable fields use this as a value with
175
+ // omitempty; do not wrap it in a pointer.
176
+ type Nullable[T any] = nullable.Nullable[T]
177
+
178
+ // Null constructs an explicit JSON null.
179
+ func Null[T any]() Nullable[T] {
180
+ return nullable.NewNullNullable[T]()
181
+ }
182
+
183
+ // NullableValue constructs a non-null value.
184
+ func NullableValue[T any](value T) Nullable[T] {
185
+ return nullable.NewNullableWithValue(value)
186
+ }
187
+ `,
188
+ 'one_or_many.go': `package openmeter
189
+
190
+ import (
191
+ "bytes"
192
+ "encoding/json"
193
+ )
194
+
195
+ // OneOrMany represents a JSON value that accepts either one T or an array of T.
196
+ // The zero value holds neither variant and marshals as JSON null.
197
+ type OneOrMany[T any] struct {
198
+ one *T
199
+ many []T
200
+ }
201
+
202
+ // One wraps a single value.
203
+ func One[T any](value T) OneOrMany[T] {
204
+ return OneOrMany[T]{one: &value}
205
+ }
206
+
207
+ // Many wraps an array. A non-nil empty slice is encoded as [].
208
+ func Many[T any](values []T) OneOrMany[T] {
209
+ return OneOrMany[T]{many: values}
210
+ }
211
+
212
+ // AsOne returns the single value. The boolean is false when the array variant
213
+ // is set or the value is empty.
214
+ func (value OneOrMany[T]) AsOne() (*T, bool) {
215
+ if value.many != nil || value.one == nil {
216
+ return nil, false
217
+ }
218
+ return value.one, true
219
+ }
220
+
221
+ // AsMany returns the array. The boolean is false when the single-value variant
222
+ // is set or the value is empty.
223
+ func (value OneOrMany[T]) AsMany() ([]T, bool) {
224
+ if value.many == nil {
225
+ return nil, false
226
+ }
227
+ return value.many, true
228
+ }
229
+
230
+ func (value OneOrMany[T]) MarshalJSON() ([]byte, error) {
231
+ if value.many != nil {
232
+ return json.Marshal(value.many)
233
+ }
234
+ if value.one != nil {
235
+ return json.Marshal(value.one)
236
+ }
237
+ return []byte("null"), nil
238
+ }
239
+
240
+ func (value *OneOrMany[T]) UnmarshalJSON(data []byte) error {
241
+ *value = OneOrMany[T]{}
242
+ data = bytes.TrimSpace(data)
243
+ if bytes.Equal(data, []byte("null")) {
244
+ return nil
245
+ }
246
+ if len(data) > 0 && data[0] == '[' {
247
+ return json.Unmarshal(data, &value.many)
248
+ }
249
+ var one T
250
+ if err := json.Unmarshal(data, &one); err != nil {
251
+ return err
252
+ }
253
+ value.one = &one
254
+ return nil
255
+ }
256
+ `,
257
+ 'option.go': `package openmeter
258
+
259
+ import (
260
+ "net/http"
261
+ "runtime/debug"
262
+ )
263
+
264
+ // Version is the SDK version reported in the default User-Agent. When the SDK
265
+ // is consumed as a module dependency it is resolved from the consumer's build
266
+ // info (the module version selected by their go.mod), so tagged releases need
267
+ // no stamping commit. Builds where that is unavailable — the module itself,
268
+ // replace directives, vendored trees without version data — fall back to the
269
+ // sdk-version emitter option ("{{SDK_VERSION}}").
270
+ var Version = resolveVersion()
271
+
272
+ func resolveVersion() string {
273
+ if info, ok := debug.ReadBuildInfo(); ok {
274
+ for _, dep := range info.Deps {
275
+ if dep.Path != "{{MODULE_PATH}}" || dep.Replace != nil {
276
+ continue
277
+ }
278
+ if dep.Version != "" && dep.Version != "(devel)" {
279
+ return dep.Version
280
+ }
281
+ }
282
+ }
283
+ return "{{SDK_VERSION}}"
284
+ }
285
+
286
+ var defaultUserAgent = "openmeter-go-sdk/" + Version
287
+
288
+ // Option configures a Client during New.
289
+ type Option func(*Client)
290
+
291
+ // WithToken sets the bearer token sent in the Authorization header of every
292
+ // request. The header is applied during request construction, so it is honored
293
+ // regardless of any client injected via WithHTTPClient.
294
+ func WithToken(token string) Option {
295
+ return func(c *Client) {
296
+ c.token = token
297
+ }
298
+ }
299
+
300
+ // WithHTTPClient replaces the default *http.Client. The provided client owns all
301
+ // transport behavior: retries, timeouts, proxies, TLS, and tracing. Pass nil to
302
+ // keep the default.
303
+ func WithHTTPClient(hc *http.Client) Option {
304
+ return func(c *Client) {
305
+ if hc != nil {
306
+ c.httpClient = hc
307
+ }
308
+ }
309
+ }
310
+
311
+ // WithUserAgent overrides the User-Agent header sent with each request.
312
+ func WithUserAgent(ua string) Option {
313
+ return func(c *Client) {
314
+ if ua != "" {
315
+ c.userAgent = ua
316
+ }
317
+ }
318
+ }
319
+ `,
320
+ 'pagination.go': `package openmeter
321
+
322
+ import (
323
+ "fmt"
324
+ "iter"
325
+ "net/url"
326
+ "strconv"
327
+ )
328
+
329
+ const defaultListPageSize = 100
330
+ const maxPages = 10_000
331
+
332
+ // PageParams selects a numbered page.
333
+ type PageParams struct {
334
+ Size *int
335
+ Number *int
336
+ }
337
+
338
+ // CursorPageParams selects a cursor page.
339
+ type CursorPageParams struct {
340
+ Size *int
341
+ After *string
342
+ Before *string
343
+ }
344
+
345
+ type PageMeta struct {
346
+ Number int \`json:"number"\`
347
+ Size int \`json:"size"\`
348
+ Total int \`json:"total"\`
349
+ }
350
+
351
+ type PaginatedMeta struct {
352
+ Page PageMeta \`json:"page"\`
353
+ }
354
+
355
+ func addPageParams(q url.Values, page *PageParams) {
356
+ if page == nil {
357
+ return
358
+ }
359
+ if page.Size != nil {
360
+ setDeepObjectString(q, "page", "size", strconv.Itoa(*page.Size))
361
+ }
362
+ if page.Number != nil {
363
+ setDeepObjectString(q, "page", "number", strconv.Itoa(*page.Number))
364
+ }
365
+ }
366
+
367
+ func addCursorPageParams(q url.Values, page *CursorPageParams) {
368
+ if page == nil {
369
+ return
370
+ }
371
+ if page.Size != nil {
372
+ setDeepObjectString(q, "page", "size", strconv.Itoa(*page.Size))
373
+ }
374
+ if page.After != nil {
375
+ setDeepObjectString(q, "page", "after", *page.After)
376
+ }
377
+ if page.Before != nil {
378
+ setDeepObjectString(q, "page", "before", *page.Before)
379
+ }
380
+ }
381
+
382
+ func paginate[T any](start *PageParams, fetch func(page, size int) ([]T, int, error)) iter.Seq2[T, error] {
383
+ return func(yield func(T, error) bool) {
384
+ page, size := 1, defaultListPageSize
385
+ if start != nil {
386
+ if start.Number != nil {
387
+ page = *start.Number
388
+ }
389
+ if start.Size != nil {
390
+ size = *start.Size
391
+ }
392
+ }
393
+ seen := 0
394
+ for fetched := 0; fetched < maxPages; fetched++ {
395
+ data, total, err := fetch(page, size)
396
+ if err != nil {
397
+ var zero T
398
+ yield(zero, err)
399
+ return
400
+ }
401
+ for _, item := range data {
402
+ if !yield(item, nil) {
403
+ return
404
+ }
405
+ }
406
+ seen += len(data)
407
+ if len(data) == 0 || (total > 0 && seen >= total) {
408
+ return
409
+ }
410
+ page++
411
+ }
412
+ var zero T
413
+ yield(zero, fmt.Errorf("openmeter: pagination did not terminate within %d pages", maxPages))
414
+ }
415
+ }
416
+
417
+ func paginateCursor[T any](start *CursorPageParams, fetch func(after, before *string, size int) ([]T, *string, *string, error)) iter.Seq2[T, error] {
418
+ return func(yield func(T, error) bool) {
419
+ size := defaultListPageSize
420
+ var after, before *string
421
+ if start != nil {
422
+ after = start.After
423
+ before = start.Before
424
+ if after != nil && before != nil {
425
+ var zero T
426
+ yield(zero, fmt.Errorf("openmeter: cursor pagination cannot use both after and before"))
427
+ return
428
+ }
429
+ if start.Size != nil {
430
+ size = *start.Size
431
+ }
432
+ }
433
+ reverse := before != nil
434
+ for fetched := 0; fetched < maxPages; fetched++ {
435
+ data, next, previous, err := fetch(after, before, size)
436
+ if err != nil {
437
+ var zero T
438
+ yield(zero, err)
439
+ return
440
+ }
441
+ for _, item := range data {
442
+ if !yield(item, nil) {
443
+ return
444
+ }
445
+ }
446
+ if reverse {
447
+ if previous == nil || *previous == "" || len(data) == 0 {
448
+ return
449
+ }
450
+ before = previous
451
+ } else {
452
+ if next == nil || *next == "" || len(data) == 0 {
453
+ return
454
+ }
455
+ after = next
456
+ }
457
+ }
458
+ var zero T
459
+ yield(zero, fmt.Errorf("openmeter: cursor pagination did not terminate within %d pages", maxPages))
460
+ }
461
+ }
462
+ `,
463
+ 'ptr.go': `package openmeter
464
+
465
+ import "time"
466
+
467
+ // Pointer helpers for populating optional request fields inline. They mirror the
468
+ // convention used by other Go cloud SDKs (e.g. aws.String), keeping call sites
469
+ // free of one-off address-of locals.
470
+
471
+ // Ptr returns a pointer to v. It is the generic form covering any type; the
472
+ // typed String/Int/Bool/Time below remain because they let the compiler infer
473
+ // the element type at call sites where a bare literal would not (e.g.
474
+ // String("x") vs Ptr("x"), which are equivalent, but Int(1) avoids Ptr[int](1)).
475
+ func Ptr[T any](v T) *T { return &v }
476
+
477
+ // String returns a pointer to s.
478
+ func String(s string) *string { return &s }
479
+
480
+ // Int returns a pointer to i.
481
+ func Int(i int) *int { return &i }
482
+
483
+ // Bool returns a pointer to b.
484
+ func Bool(b bool) *bool { return &b }
485
+
486
+ // Time returns a pointer to t.
487
+ func Time(t time.Time) *time.Time { return &t }
488
+ `,
489
+ 'query.go': `package openmeter
490
+
491
+ import (
492
+ "net/url"
493
+ "strconv"
494
+ "strings"
495
+ "time"
496
+ )
497
+
498
+ // SortOrder is the direction of a sort expression.
499
+ type SortOrder string
500
+
501
+ const (
502
+ SortOrderAsc SortOrder = "asc"
503
+ SortOrderDesc SortOrder = "desc"
504
+ )
505
+
506
+ // Sort selects a wire field and optional direction.
507
+ type Sort struct {
508
+ By string
509
+ Order SortOrder
510
+ }
511
+
512
+ func setDeepObjectString(q url.Values, prefix, key, value string) {
513
+ q.Set(prefix+"["+key+"]", value)
514
+ }
515
+
516
+ func addSort(q url.Values, name string, sort *Sort) {
517
+ if sort == nil || sort.By == "" {
518
+ return
519
+ }
520
+ value := sort.By
521
+ if sort.Order != "" {
522
+ value += " " + string(sort.Order)
523
+ }
524
+ q.Set(name, value)
525
+ }
526
+
527
+ func addStringFilter(q url.Values, prefix string, f *StringFilter) {
528
+ if f == nil {
529
+ return
530
+ }
531
+ if f.Eq != nil {
532
+ setDeepObjectString(q, prefix, "eq", *f.Eq)
533
+ }
534
+ if f.Neq != nil {
535
+ setDeepObjectString(q, prefix, "neq", *f.Neq)
536
+ }
537
+ if f.Contains != nil {
538
+ setDeepObjectString(q, prefix, "contains", *f.Contains)
539
+ }
540
+ if f.Gt != nil {
541
+ setDeepObjectString(q, prefix, "gt", *f.Gt)
542
+ }
543
+ if f.Gte != nil {
544
+ setDeepObjectString(q, prefix, "gte", *f.Gte)
545
+ }
546
+ if f.Lt != nil {
547
+ setDeepObjectString(q, prefix, "lt", *f.Lt)
548
+ }
549
+ if f.Lte != nil {
550
+ setDeepObjectString(q, prefix, "lte", *f.Lte)
551
+ }
552
+ if len(f.Oeq) > 0 {
553
+ setDeepObjectString(q, prefix, "oeq", strings.Join(f.Oeq, ","))
554
+ }
555
+ if len(f.Ocontains) > 0 {
556
+ setDeepObjectString(q, prefix, "ocontains", strings.Join(f.Ocontains, ","))
557
+ }
558
+ if f.Exists != nil {
559
+ setDeepObjectString(q, prefix, "exists", strconv.FormatBool(*f.Exists))
560
+ }
561
+ }
562
+
563
+ func addStringExactFilter(q url.Values, prefix string, f *StringExactFilter) {
564
+ if f == nil {
565
+ return
566
+ }
567
+ if f.Eq != nil {
568
+ setDeepObjectString(q, prefix, "eq", *f.Eq)
569
+ }
570
+ if f.Neq != nil {
571
+ setDeepObjectString(q, prefix, "neq", *f.Neq)
572
+ }
573
+ if len(f.Oeq) > 0 {
574
+ setDeepObjectString(q, prefix, "oeq", strings.Join(f.Oeq, ","))
575
+ }
576
+ }
577
+
578
+ func addDateTimeFilter(q url.Values, prefix string, f *DateTimeFilter) {
579
+ if f == nil {
580
+ return
581
+ }
582
+ values := []struct {
583
+ name string
584
+ value *time.Time
585
+ }{
586
+ {"eq", f.Eq},
587
+ {"gt", f.Gt},
588
+ {"gte", f.Gte},
589
+ {"lt", f.Lt},
590
+ {"lte", f.Lte},
591
+ }
592
+ for _, value := range values {
593
+ if value.value != nil {
594
+ setDeepObjectString(q, prefix, value.name, value.value.Format(time.RFC3339Nano))
595
+ }
596
+ }
597
+ }
598
+
599
+ func addNumericFilter(q url.Values, prefix string, f *NumericFilter) {
600
+ if f == nil {
601
+ return
602
+ }
603
+ format := func(value float64) string {
604
+ return strconv.FormatFloat(value, 'g', -1, 64)
605
+ }
606
+ values := []struct {
607
+ name string
608
+ value *float64
609
+ }{
610
+ {"eq", f.Eq},
611
+ {"neq", f.Neq},
612
+ {"gt", f.Gt},
613
+ {"gte", f.Gte},
614
+ {"lt", f.Lt},
615
+ {"lte", f.Lte},
616
+ }
617
+ for _, value := range values {
618
+ if value.value != nil {
619
+ setDeepObjectString(q, prefix, value.name, format(*value.value))
620
+ }
621
+ }
622
+ if len(f.Oeq) > 0 {
623
+ values := make([]string, 0, len(f.Oeq))
624
+ for _, value := range f.Oeq {
625
+ values = append(values, format(value))
626
+ }
627
+ setDeepObjectString(q, prefix, "oeq", strings.Join(values, ","))
628
+ }
629
+ }
630
+
631
+ func addBooleanFilter(q url.Values, prefix string, f *BooleanFilter) {
632
+ if f != nil && f.Eq != nil {
633
+ setDeepObjectString(q, prefix, "eq", strconv.FormatBool(*f.Eq))
634
+ }
635
+ }
636
+ `,
637
+ 'request_content_type.go': `package openmeter
638
+
639
+ import (
640
+ "context"
641
+ "net/http"
642
+ "net/url"
643
+ )
644
+
645
+ func (c *Client) newRequestWithContentType(
646
+ ctx context.Context,
647
+ method string,
648
+ apiPath string,
649
+ query url.Values,
650
+ body any,
651
+ contentType string,
652
+ accept string,
653
+ ) (*http.Request, error) {
654
+ req, err := c.newRequest(ctx, method, apiPath, query, body, accept)
655
+ if err != nil {
656
+ return nil, err
657
+ }
658
+ if body != nil && contentType != "" {
659
+ req.Header.Set("Content-Type", contentType)
660
+ }
661
+ return req, nil
662
+ }
663
+
664
+ func optionalBody[T any](body *T) any {
665
+ if body == nil {
666
+ return nil
667
+ }
668
+ return body
669
+ }
670
+ `,
671
+ 'transport.go': `package openmeter
672
+
673
+ import (
674
+ "bytes"
675
+ "context"
676
+ "encoding/json"
677
+ "fmt"
678
+ "io"
679
+ "net/http"
680
+ "net/url"
681
+ "time"
682
+ )
683
+
684
+ const (
685
+ contentTypeJSON = "application/json"
686
+
687
+ // defaultRequestTimeout bounds a buffered request when the caller's context
688
+ // carries no deadline, so a call can't hang forever by default. It is applied
689
+ // via context, not http.Client.Timeout, so it never interferes with streaming
690
+ // body reads. Callers wanting a different bound pass their own context
691
+ // deadline; requests made through Stream method variants are never bounded by
692
+ // this and rely solely on the caller's context.
693
+ defaultRequestTimeout = 30 * time.Second
694
+
695
+ // maxBufferedResponse caps how much of a response the buffered read paths
696
+ // (JSON decoding, byte-returning text methods) hold in memory, guarding
697
+ // against unbounded growth from an unexpectedly large payload. Exports that
698
+ // may exceed this should use the operation's Stream method variant.
699
+ maxBufferedResponse = 10 << 20 // 10 MiB
700
+ // maxErrorBody caps how much of a non-2xx body is read to build an APIError.
701
+ maxErrorBody = 1 << 20 // 1 MiB
702
+ )
703
+
704
+ // defaultHTTPClient builds the SDK's default transport.
705
+ //
706
+ // It deliberately sets no http.Client.Timeout: that field also bounds reading the
707
+ // response body and would abort a streamed export mid-read. Per-call deadlines
708
+ // come from the request context instead (see defaultRequestTimeout).
709
+ func defaultHTTPClient() *http.Client {
710
+ return &http.Client{}
711
+ }
712
+
713
+ // newRequest builds an *http.Request against the client base URL. body, when
714
+ // non-nil, is JSON-encoded and Content-Type is set accordingly. accept sets the
715
+ // Accept header (JSON or CSV) to drive server-side content negotiation.
716
+ func (c *Client) newRequest(ctx context.Context, method, apiPath string, query url.Values, body any, accept string) (*http.Request, error) {
717
+ u := c.resolve(apiPath)
718
+ if len(query) > 0 {
719
+ merged := u.Query()
720
+ for key, values := range query {
721
+ merged.Del(key)
722
+ for _, value := range values {
723
+ merged.Add(key, value)
724
+ }
725
+ }
726
+ u.RawQuery = merged.Encode()
727
+ }
728
+
729
+ var bodyReader io.Reader
730
+ if body != nil {
731
+ buf, err := json.Marshal(body)
732
+ if err != nil {
733
+ return nil, fmt.Errorf("openmeter: encoding request body: %w", err)
734
+ }
735
+
736
+ bodyReader = bytes.NewReader(buf)
737
+ }
738
+
739
+ req, err := http.NewRequestWithContext(ctx, method, u.String(), bodyReader)
740
+ if err != nil {
741
+ return nil, fmt.Errorf("openmeter: building request: %w", err)
742
+ }
743
+
744
+ if body != nil {
745
+ req.Header.Set("Content-Type", contentTypeJSON)
746
+ }
747
+
748
+ if accept != "" {
749
+ req.Header.Set("Accept", accept)
750
+ }
751
+
752
+ if c.userAgent != "" {
753
+ req.Header.Set("User-Agent", c.userAgent)
754
+ }
755
+
756
+ if c.token != "" {
757
+ req.Header.Set("Authorization", "Bearer "+c.token)
758
+ }
759
+
760
+ return req, nil
761
+ }
762
+
763
+ // doJSON executes req and decodes a 2xx JSON body into out (out may be nil to
764
+ // discard the body). Non-2xx responses are converted to *APIError.
765
+ func (c *Client) doJSON(req *http.Request, out any) error {
766
+ body, err := c.doRaw(req)
767
+ if err != nil {
768
+ return err
769
+ }
770
+
771
+ if out == nil || len(body) == 0 {
772
+ return nil
773
+ }
774
+
775
+ if err := json.Unmarshal(body, out); err != nil {
776
+ return fmt.Errorf("openmeter: decoding response body: %w", err)
777
+ }
778
+
779
+ return nil
780
+ }
781
+
782
+ // withDefaultDeadline bounds a buffered request to defaultRequestTimeout when
783
+ // the caller's context carries no deadline, so a call can't hang forever by
784
+ // default. When the caller already set a deadline, the request is returned
785
+ // unchanged. The returned cancel func must always be called; it is a no-op in
786
+ // the pass-through case. Streaming requests intentionally skip this so a long
787
+ // body read is bounded only by the caller's own context.
788
+ func withDefaultDeadline(req *http.Request) (*http.Request, context.CancelFunc) {
789
+ if _, ok := req.Context().Deadline(); ok {
790
+ return req, func() {}
791
+ }
792
+
793
+ ctx, cancel := context.WithTimeout(req.Context(), defaultRequestTimeout)
794
+ return req.WithContext(ctx), cancel
795
+ }
796
+
797
+ // doRaw executes req, returns the 2xx body (capped at maxBufferedResponse), and
798
+ // converts any non-2xx response into an *APIError. Use doStream for responses
799
+ // that may exceed the buffered limit (e.g. large CSV exports).
800
+ func (c *Client) doRaw(req *http.Request) ([]byte, error) {
801
+ req, cancel := withDefaultDeadline(req)
802
+ defer cancel()
803
+
804
+ resp, err := c.httpClient.Do(req)
805
+ if err != nil {
806
+ return nil, fmt.Errorf("openmeter: request failed: %w", err)
807
+ }
808
+ defer resp.Body.Close()
809
+
810
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
811
+ body, _ := readAllCapped(resp.Body, maxErrorBody)
812
+ return nil, newAPIError(resp.StatusCode, body)
813
+ }
814
+
815
+ body, err := readAllCapped(resp.Body, maxBufferedResponse)
816
+ if err != nil {
817
+ return nil, err
818
+ }
819
+
820
+ return body, nil
821
+ }
822
+
823
+ // doStream executes req and returns the live response for streaming. The caller
824
+ // owns resp.Body and must close it. Non-2xx responses are converted to
825
+ // *APIError (with the body closed) exactly as the buffered paths do, so a
826
+ // successful return always carries a readable body.
827
+ func (c *Client) doStream(req *http.Request) (*http.Response, error) {
828
+ resp, err := c.httpClient.Do(req)
829
+ if err != nil {
830
+ return nil, fmt.Errorf("openmeter: request failed: %w", err)
831
+ }
832
+
833
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
834
+ defer resp.Body.Close()
835
+
836
+ body, _ := readAllCapped(resp.Body, maxErrorBody)
837
+
838
+ return nil, newAPIError(resp.StatusCode, body)
839
+ }
840
+
841
+ return resp, nil
842
+ }
843
+
844
+ // readAllCapped reads up to max bytes from r and returns an error if the source
845
+ // carries more, bounding how much a buffered response can hold in memory. On
846
+ // error it still returns whatever bytes were read (capped at max) so callers can
847
+ // preserve partial diagnostic content, e.g. an oversized or truncated error body.
848
+ func readAllCapped(r io.Reader, max int64) ([]byte, error) {
849
+ body, err := io.ReadAll(io.LimitReader(r, max+1))
850
+ if err != nil {
851
+ return body, fmt.Errorf("openmeter: reading response body: %w", err)
852
+ }
853
+
854
+ if int64(len(body)) > max {
855
+ return body[:max], fmt.Errorf("openmeter: response body exceeds %d-byte limit; use a streaming method for large payloads", max)
856
+ }
857
+
858
+ return body, nil
859
+ }
860
+ `,
861
+ 'types.go': `package openmeter
862
+
863
+ // Numeric represents an arbitrary-precision number. The API encodes it as a
864
+ // decimal string (e.g. "12.3456") to avoid float precision loss, so the SDK
865
+ // surfaces it as a string. Parse with a decimal library when arithmetic is needed.
866
+ type Numeric = string
867
+ `,
868
+ }
api/spec/packages/typespec-go/src/stdlib.ts ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as go from '@alloy-js/go'
2
+
3
+ export const context = go.createModule(
4
+ 'context',
5
+ {
6
+ kind: 'package',
7
+ members: {
8
+ Context: { kind: 'interface', members: {} },
9
+ },
10
+ } as const,
11
+ true,
12
+ )
13
+
14
+ export const url = go.createModule(
15
+ 'url',
16
+ {
17
+ kind: 'package',
18
+ path: 'net/url',
19
+ members: {
20
+ URL: { kind: 'struct', members: {} },
21
+ Values: {
22
+ kind: 'type',
23
+ members: {
24
+ Add: { kind: 'method' },
25
+ Set: { kind: 'method' },
26
+ },
27
+ },
28
+ Parse: { kind: 'function' },
29
+ PathEscape: { kind: 'function' },
30
+ PathUnescape: { kind: 'function' },
31
+ },
32
+ } as const,
33
+ true,
34
+ )
35
+
36
+ export const iter = go.createModule(
37
+ 'iter',
38
+ {
39
+ kind: 'package',
40
+ path: 'iter',
41
+ members: {
42
+ Seq2: { kind: 'type', members: {} },
43
+ },
44
+ } as const,
45
+ true,
46
+ )
47
+
48
+ export const strings = go.createModule(
49
+ 'strings',
50
+ {
51
+ kind: 'package',
52
+ path: 'strings',
53
+ members: {
54
+ HasSuffix: { kind: 'function' },
55
+ Join: { kind: 'function' },
56
+ ReplaceAll: { kind: 'function' },
57
+ TrimPrefix: { kind: 'function' },
58
+ },
59
+ } as const,
60
+ true,
61
+ )
62
+
63
+ export const json = go.createModule(
64
+ 'json',
65
+ {
66
+ kind: 'package',
67
+ path: 'encoding/json',
68
+ members: {
69
+ Marshal: { kind: 'function' },
70
+ RawMessage: { kind: 'type', members: {} },
71
+ Unmarshal: { kind: 'function' },
72
+ },
73
+ } as const,
74
+ true,
75
+ )
76
+
77
+ export const strconv = go.createModule(
78
+ 'strconv',
79
+ {
80
+ kind: 'package',
81
+ members: {
82
+ FormatBool: { kind: 'function' },
83
+ FormatFloat: { kind: 'function' },
84
+ FormatInt: { kind: 'function' },
85
+ FormatUint: { kind: 'function' },
86
+ },
87
+ } as const,
88
+ true,
89
+ )
api/spec/packages/typespec-go/test/assembly.test.ts ADDED
@@ -0,0 +1,385 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createTestHost, createTestRunner } from '@typespec/compiler/testing'
2
+ import { HttpTestLibrary } from '@typespec/http/testing'
3
+ import { OpenAPITestLibrary } from '@typespec/openapi/testing'
4
+ import { describe, expect, it } from 'vitest'
5
+ import type { Program } from '@typespec/compiler'
6
+ import {
7
+ cleanOutputDirectory,
8
+ packageDocHeader,
9
+ prepareRuntimeTemplate,
10
+ } from '../dist/emitter.js'
11
+ import { goExportedName } from '../dist/go-types.js'
12
+ import {
13
+ collectHttpOperations,
14
+ describeOperations,
15
+ } from '../dist/operations.js'
16
+ import { readmeFile } from '../dist/readme.js'
17
+ import { RESERVED_GO_SYMBOL_NAMES } from '../dist/runtime-symbols.js'
18
+ import { RUNTIME_TEMPLATES } from '../dist/runtime-templates.js'
19
+
20
+ const GENERATED_HEADER =
21
+ '// Code generated by @openmeter/typespec-go. DO NOT EDIT.'
22
+
23
+ /** Package-level exported identifiers declared by a runtime Go template:
24
+ * top-level func/type/const/var declarations plus members of top-level
25
+ * const/var blocks. Methods are skipped; they do not occupy the package
26
+ * namespace. */
27
+ function templateExportedSymbols(content: string): string[] {
28
+ const names: string[] = []
29
+ let inBlock = false
30
+ for (const line of content.split('\n')) {
31
+ if (inBlock) {
32
+ if (line === ')') {
33
+ inBlock = false
34
+ continue
35
+ }
36
+ const member = line.match(/^\t([A-Za-z_][A-Za-z0-9_]*)/)
37
+ if (member) {
38
+ names.push(member[1]!)
39
+ }
40
+ continue
41
+ }
42
+ if (/^(const|var) \($/.test(line)) {
43
+ inBlock = true
44
+ continue
45
+ }
46
+ if (line.startsWith('func (')) {
47
+ continue
48
+ }
49
+ const decl = line.match(/^(?:func|type|const|var) ([A-Za-z_][A-Za-z0-9_]*)/)
50
+ if (decl) {
51
+ names.push(decl[1]!)
52
+ }
53
+ }
54
+ return names.filter((name) => /^[A-Z]/.test(name))
55
+ }
56
+
57
+ async function compileFixture(code: string): Promise<Program> {
58
+ const host = await createTestHost({
59
+ libraries: [HttpTestLibrary, OpenAPITestLibrary],
60
+ })
61
+ const runner = await createTestRunner(host)
62
+ await runner.compile(`
63
+ import "@typespec/http";
64
+ import "@typespec/openapi";
65
+ using TypeSpec.Http;
66
+ using TypeSpec.OpenAPI;
67
+ ${code}
68
+ `)
69
+ return runner.program
70
+ }
71
+
72
+ describe('runtime template assembly', () => {
73
+ it('keeps RESERVED_GO_SYMBOL_NAMES in sync with runtime template exports', () => {
74
+ const exported = new Map<string, string>()
75
+ for (const [path, content] of Object.entries(RUNTIME_TEMPLATES)) {
76
+ if (!path.endsWith('.go')) {
77
+ continue
78
+ }
79
+ for (const name of templateExportedSymbols(content)) {
80
+ exported.set(name, path)
81
+ }
82
+ }
83
+
84
+ // Guard the extractor itself: a regex regression that finds nothing (or
85
+ // misses const-block members) must fail here, not silently pass.
86
+ expect(exported.get('APIError')).toBe('errors.go')
87
+ expect(exported.get('SortOrderAsc')).toBe('query.go')
88
+
89
+ const missing = [...exported].filter(
90
+ ([name]) => !RESERVED_GO_SYMBOL_NAMES.has(name),
91
+ )
92
+ expect(
93
+ missing,
94
+ `runtime template exports missing from RESERVED_GO_SYMBOL_NAMES in runtime-symbols.ts: ${missing
95
+ .map(([name, path]) => `${name} (${path})`)
96
+ .join(', ')}`,
97
+ ).toEqual([])
98
+ })
99
+
100
+ it('interpolates go.mod from the module-path option with a tidy-canonical require block', () => {
101
+ const gomod = prepareRuntimeTemplate(
102
+ 'go.mod',
103
+ RUNTIME_TEMPLATES['go.mod']!,
104
+ 'openmeter',
105
+ 'github.com/openmeterio/openmeter/api/v3/client',
106
+ '0.0.0-dev',
107
+ '1.23',
108
+ )
109
+
110
+ expect(gomod).toContain(
111
+ 'module github.com/openmeterio/openmeter/api/v3/client\n',
112
+ )
113
+ expect(gomod).toContain('\ngo 1.23\n')
114
+ expect(gomod).toContain(
115
+ '\nrequire github.com/oapi-codegen/nullable v1.2.0\n',
116
+ )
117
+ expect(gomod).not.toContain('{{MODULE_PATH}}')
118
+ expect(gomod).not.toContain('require (')
119
+ })
120
+
121
+ it('stamps the go-version option into the go.mod go directive, overriding the 1.23 default', () => {
122
+ const gomod = prepareRuntimeTemplate(
123
+ 'go.mod',
124
+ RUNTIME_TEMPLATES['go.mod']!,
125
+ 'openmeter',
126
+ 'github.com/openmeterio/openmeter/api/v3/client',
127
+ '0.0.0-dev',
128
+ '1.24',
129
+ )
130
+
131
+ expect(gomod).toContain('\ngo 1.24\n')
132
+ expect(gomod).not.toContain('go 1.23')
133
+ expect(gomod).not.toContain('{{GO_VERSION}}')
134
+ })
135
+
136
+ it('interpolates the module path into resolveVersion and the sdk-version fallback into its return', () => {
137
+ const option = prepareRuntimeTemplate(
138
+ 'option.go',
139
+ RUNTIME_TEMPLATES['option.go']!,
140
+ 'openmeter',
141
+ 'github.com/openmeterio/openmeter/api/v3/client',
142
+ '1.2.3',
143
+ '1.23',
144
+ )
145
+
146
+ expect(option).toContain('var Version = resolveVersion()')
147
+ expect(option).toContain('func resolveVersion() string')
148
+ expect(option).toContain(
149
+ 'dep.Path != "github.com/openmeterio/openmeter/api/v3/client"',
150
+ )
151
+ expect(option).toContain('return "1.2.3"')
152
+ expect(option).not.toContain('{{MODULE_PATH}}')
153
+ expect(option).not.toContain('{{SDK_VERSION}}')
154
+ })
155
+
156
+ it('falls back to the 0.0.0-dev default sdk-version when the option is omitted', () => {
157
+ const option = prepareRuntimeTemplate(
158
+ 'option.go',
159
+ RUNTIME_TEMPLATES['option.go']!,
160
+ 'openmeter',
161
+ 'github.com/openmeterio/openmeter/api/v3/client',
162
+ '0.0.0-dev',
163
+ '1.23',
164
+ )
165
+
166
+ expect(option).toContain('return "0.0.0-dev"')
167
+ })
168
+
169
+ it('emits every runtime Go file with the generated header before the package clause', () => {
170
+ for (const [path, content] of Object.entries(RUNTIME_TEMPLATES)) {
171
+ if (!path.endsWith('.go')) {
172
+ continue
173
+ }
174
+ const prepared = prepareRuntimeTemplate(
175
+ path,
176
+ content,
177
+ 'sdk',
178
+ 'example.com/sdk',
179
+ '0.0.0-dev',
180
+ '1.23',
181
+ )
182
+ const lines = prepared.split('\n')
183
+ expect(lines[0], path).toBe(GENERATED_HEADER)
184
+ expect(lines[1], path).toBe('')
185
+ expect(prepared, path).toContain('\npackage sdk\n')
186
+ expect(prepared, path).not.toContain('{{SDK_VERSION}}')
187
+ expect(prepared, path).not.toContain('{{MODULE_PATH}}')
188
+ }
189
+ })
190
+
191
+ it('passes non-Go templates through untouched', () => {
192
+ expect(
193
+ prepareRuntimeTemplate(
194
+ 'go.sum',
195
+ RUNTIME_TEMPLATES['go.sum']!,
196
+ 'openmeter',
197
+ 'example.com/sdk',
198
+ '0.0.0-dev',
199
+ '1.23',
200
+ ),
201
+ ).toBe(RUNTIME_TEMPLATES['go.sum'])
202
+ })
203
+
204
+ it('exposes read accessors and null zero-value docs on OneOrMany', () => {
205
+ const oneOrMany = RUNTIME_TEMPLATES['one_or_many.go']!
206
+ expect(oneOrMany).toContain('func (value OneOrMany[T]) AsOne() (*T, bool)')
207
+ expect(oneOrMany).toContain(
208
+ 'func (value OneOrMany[T]) AsMany() ([]T, bool)',
209
+ )
210
+ expect(oneOrMany).toContain('marshals as JSON null')
211
+ })
212
+
213
+ it('keeps spec-derived method names out of the static transport template', () => {
214
+ const transport = RUNTIME_TEMPLATES['transport.go']!
215
+ expect(transport).not.toContain('QueryCSVStream')
216
+ expect(transport).not.toContain('contentTypeCSV')
217
+ })
218
+ })
219
+
220
+ describe('doc.go package documentation', () => {
221
+ it('renders the generated marker detached from the package godoc', () => {
222
+ const header = packageDocHeader('openmeter')
223
+ const lines = header.split('\n')
224
+
225
+ expect(lines[0]).toBe(GENERATED_HEADER)
226
+ // The blank separator keeps the marker out of the package documentation.
227
+ expect(lines[1]).toBe('')
228
+ expect(lines[2]).toMatch(/^\/\/ Package openmeter provides/)
229
+ // Every remaining line is comment content, and the trailing newline keeps
230
+ // the godoc attached to the package clause the source file renders next.
231
+ expect(header.endsWith('\n')).toBe(true)
232
+ for (const line of lines.slice(2, -1)) {
233
+ expect(line).toMatch(/^\/\//)
234
+ }
235
+ expect(header).not.toContain('\npackage ')
236
+
237
+ for (const topic of ['WithToken', 'APIError', 'errors.As', 'iter.Seq2']) {
238
+ expect(header).toContain(topic)
239
+ }
240
+ })
241
+ })
242
+
243
+ describe('output cleaning', () => {
244
+ it('preserves hand-written Go tests and testdata across regeneration', async () => {
245
+ const removed: string[] = []
246
+ await cleanOutputDirectory(
247
+ {
248
+ readDir: async () => [
249
+ 'client.go',
250
+ 'go.mod',
251
+ 'README.md',
252
+ 'wire_test.go',
253
+ 'testdata',
254
+ ],
255
+ rm: async (path: string) => {
256
+ removed.push(path)
257
+ },
258
+ },
259
+ '/out',
260
+ )
261
+
262
+ expect(removed.sort()).toEqual([
263
+ '/out/README.md',
264
+ '/out/client.go',
265
+ '/out/go.mod',
266
+ ])
267
+ })
268
+
269
+ it('treats a missing output directory as already clean', async () => {
270
+ await cleanOutputDirectory(
271
+ {
272
+ readDir: async () => {
273
+ const error = new Error('missing') as NodeJS.ErrnoException
274
+ error.code = 'ENOENT'
275
+ throw error
276
+ },
277
+ rm: async () => {
278
+ throw new Error('rm must not run when the directory is missing')
279
+ },
280
+ },
281
+ '/out',
282
+ )
283
+ })
284
+
285
+ it('surfaces non-ENOENT filesystem errors', async () => {
286
+ await expect(
287
+ cleanOutputDirectory(
288
+ {
289
+ readDir: async () => {
290
+ const error = new Error(
291
+ 'permission denied',
292
+ ) as NodeJS.ErrnoException
293
+ error.code = 'EACCES'
294
+ throw error
295
+ },
296
+ rm: async () => {},
297
+ },
298
+ '/out',
299
+ ),
300
+ ).rejects.toThrow('permission denied')
301
+ })
302
+ })
303
+
304
+ describe('generated README', () => {
305
+ async function compileReadme(): Promise<{
306
+ readme: string
307
+ methodNames: string[]
308
+ csvMethodName: string
309
+ }> {
310
+ const program = await compileFixture(`
311
+ @service namespace Test;
312
+
313
+ model Item {
314
+ id: string;
315
+ }
316
+
317
+ @route("/items")
318
+ interface Operations {
319
+ @get op list(): Item[];
320
+
321
+ @route("/export")
322
+ @get op exportCsv(): {
323
+ @header contentType: "text/csv";
324
+ @body _: string;
325
+ };
326
+ }
327
+ `)
328
+
329
+ const operations = collectHttpOperations(program)
330
+ const described = describeOperations(program, 'Test', operations)
331
+ const csv = described.find(
332
+ (operation) => operation.responseContentType === 'text/csv',
333
+ )!
334
+ const readme = readmeFile(
335
+ program,
336
+ 'github.com/openmeterio/openmeter/api/v3/client',
337
+ 'openmeter',
338
+ [{ root: 'Test', nestPath: [], operations }],
339
+ new Map(),
340
+ )
341
+ return {
342
+ readme,
343
+ methodNames: described.map((operation) => operation.methodName),
344
+ csvMethodName: csv.methodName,
345
+ }
346
+ }
347
+
348
+ it('shows compiling examples with an om variable and openmeter qualifiers', async () => {
349
+ const { readme } = await compileReadme()
350
+
351
+ expect(readme).toContain('om, err := openmeter.New(')
352
+ expect(readme).toContain(
353
+ 'om.Meters.Create(ctx, openmeter.CreateMeterRequest{',
354
+ )
355
+ expect(readme).toContain('openmeter.MeterListParams{}')
356
+ // The local variable must not shadow the package qualifier.
357
+ expect(readme).not.toContain('client, err :=')
358
+ expect(readme).not.toContain('client.Meters')
359
+ // Examples target the unified request type names without the Input suffix.
360
+ expect(readme).not.toContain('CreateMeterRequestInput')
361
+ expect(readme).not.toContain('MeterQueryRequestInput')
362
+ })
363
+
364
+ it('lists the Stream variant of text-response operations', async () => {
365
+ const { readme, methodNames, csvMethodName } = await compileReadme()
366
+
367
+ for (const methodName of methodNames) {
368
+ expect(readme).toContain(`\`om.Test.${methodName}\``)
369
+ }
370
+ expect(readme).toContain(
371
+ `\`om.Test.${goExportedName(csvMethodName)}Stream\``,
372
+ )
373
+ })
374
+
375
+ it('documents opaque cursor tokens and backward-iteration ordering', async () => {
376
+ const { readme } = await compileReadme()
377
+
378
+ expect(readme).toContain('opaque cursor tokens')
379
+ expect(readme).toContain('`page[after]` / `page[before]`')
380
+ expect(readme.replace(/\s+/g, ' ')).toContain('not globally sorted')
381
+ expect(readme).toContain(
382
+ 'om.Meters.QueryCSVStream(ctx, "meter-id", openmeter.MeterQueryRequest{})',
383
+ )
384
+ })
385
+ })
api/spec/packages/typespec-go/test/go-types.test.ts ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createTestHost, createTestRunner } from '@typespec/compiler/testing'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { goType } from '../dist/go-types.js'
4
+ import type { Program } from '@typespec/compiler'
5
+
6
+ async function compileTypes(code: string): Promise<Program> {
7
+ const host = await createTestHost()
8
+ const runner = await createTestRunner(host)
9
+ await runner.compile(code)
10
+ return runner.program
11
+ }
12
+
13
+ describe('Go type mapping', () => {
14
+ it('maps unbounded integer scalars to int64 and keeps sized scalars exact', async () => {
15
+ const program = await compileTypes(`
16
+ model Sample {
17
+ version: integer;
18
+ quantity: safeint;
19
+ priority: int16;
20
+ octet: int8;
21
+ }
22
+ `)
23
+
24
+ const sample = program.getGlobalNamespaceType().models.get('Sample')!
25
+ const fieldType = (name: string) =>
26
+ goType(program, sample.properties.get(name)!.type).type
27
+
28
+ expect(fieldType('version')).toBe('int64')
29
+ expect(fieldType('quantity')).toBe('int64')
30
+ expect(fieldType('priority')).toBe('int16')
31
+ expect(fieldType('octet')).toBe('int8')
32
+ })
33
+
34
+ it('maps every known field filter union to its runtime filter type', async () => {
35
+ const program = await compileTypes(`
36
+ union StringFieldFilter { equals: string }
37
+ union StringFieldFilterExact { equals: string }
38
+ union ULIDFieldFilter { equals: string }
39
+ union DateTimeFieldFilter { equals: utcDateTime }
40
+ union NumericFieldFilter { equals: float64 }
41
+ union BooleanFieldFilter { equals: boolean }
42
+ `)
43
+
44
+ const unions = program.getGlobalNamespaceType().unions
45
+ const unionType = (name: string) => goType(program, unions.get(name)!).type
46
+
47
+ expect(unionType('StringFieldFilter')).toBe('StringFilter')
48
+ expect(unionType('StringFieldFilterExact')).toBe('StringExactFilter')
49
+ expect(unionType('ULIDFieldFilter')).toBe('StringExactFilter')
50
+ expect(unionType('DateTimeFieldFilter')).toBe('DateTimeFilter')
51
+ expect(unionType('NumericFieldFilter')).toBe('NumericFilter')
52
+ expect(unionType('BooleanFieldFilter')).toBe('BooleanFilter')
53
+ })
54
+
55
+ it('rejects field filter unions without a runtime filter mapping', async () => {
56
+ const program = await compileTypes(`
57
+ union DurationFieldFilter { equals: string }
58
+ `)
59
+
60
+ const union = program
61
+ .getGlobalNamespaceType()
62
+ .unions.get('DurationFieldFilter')!
63
+ expect(() => goType(program, union)).toThrow(
64
+ 'field filter union DurationFieldFilter has no runtime filter type',
65
+ )
66
+ })
67
+
68
+ it('rejects anonymous unions that mix non-string variants', async () => {
69
+ const program = await compileTypes(`
70
+ model Sample {
71
+ value: int32 | boolean;
72
+ }
73
+ `)
74
+
75
+ const value = program
76
+ .getGlobalNamespaceType()
77
+ .models.get('Sample')!
78
+ .properties.get('value')!
79
+ expect(() => goType(program, value.type)).toThrow(
80
+ 'anonymous union of [Scalar, Scalar] is not representable in Go',
81
+ )
82
+ })
83
+
84
+ it('maps explicit unknown to any but rejects other intrinsics', async () => {
85
+ const program = await compileTypes(`
86
+ model Sample {
87
+ config?: unknown;
88
+ forbidden?: null;
89
+ }
90
+ `)
91
+
92
+ const sample = program.getGlobalNamespaceType().models.get('Sample')!
93
+ expect(goType(program, sample.properties.get('config')!.type).type).toBe(
94
+ 'any',
95
+ )
96
+ expect(() =>
97
+ goType(program, sample.properties.get('forbidden')!.type),
98
+ ).toThrow('intrinsic type null is not representable in Go')
99
+ })
100
+ })
api/spec/packages/typespec-go/test/operations.test.ts ADDED
@@ -0,0 +1,586 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Model, Program } from '@typespec/compiler'
2
+ import { createTestHost, createTestRunner } from '@typespec/compiler/testing'
3
+ import { HttpTestLibrary } from '@typespec/http/testing'
4
+ import { OpenAPITestLibrary } from '@typespec/openapi/testing'
5
+ import { describe, expect, it } from 'vitest'
6
+ import {
7
+ collectHttpOperations,
8
+ describeOperations,
9
+ jsonBodyOverrides,
10
+ type GoOperation,
11
+ } from '../dist/operations.js'
12
+ import { groupOperations, operationNestPath } from '../dist/grouping.js'
13
+ import { computeDivergentTypes } from '../dist/projections.js'
14
+ import { validateUniqueTypeNames } from '../dist/emitter.js'
15
+ import { isRuntimeBackedTypeName } from '../dist/runtime-symbols.js'
16
+ import {
17
+ configureGoTypeNames,
18
+ optionalTypeName,
19
+ resolveGoTypeNames,
20
+ } from '../dist/go-types.js'
21
+
22
+ async function compileProgram(code: string): Promise<Program> {
23
+ const host = await createTestHost({
24
+ libraries: [HttpTestLibrary, OpenAPITestLibrary],
25
+ })
26
+ const runner = await createTestRunner(host)
27
+ await runner.compile(`
28
+ import "@typespec/http";
29
+ import "@typespec/openapi";
30
+ using TypeSpec.Http;
31
+ using TypeSpec.OpenAPI;
32
+ ${code}
33
+ `)
34
+
35
+ return runner.program
36
+ }
37
+
38
+ async function compileOperations(code: string): Promise<GoOperation[]> {
39
+ const program = await compileProgram(code)
40
+ const operations = collectHttpOperations(program)
41
+ return describeOperations(program, 'Test', operations)
42
+ }
43
+
44
+ describe('operation HTTP IR', () => {
45
+ it('classifies every supported query encoding from HTTP metadata', async () => {
46
+ const operations = await compileOperations(`
47
+ @service namespace Test;
48
+
49
+ namespace Common {
50
+ model SortQuery {
51
+ by: string;
52
+ order?: "asc" | "desc";
53
+ }
54
+ }
55
+
56
+ model ItemFilter {
57
+ key?: string;
58
+ }
59
+
60
+ @route("/items")
61
+ interface Operations {
62
+ @get
63
+ op list(
64
+ @query(#{ style: "deepObject", explode: true })
65
+ page?: {
66
+ size?: integer;
67
+ number?: integer;
68
+ },
69
+ @query sort?: Common.SortQuery,
70
+ @query(#{ style: "deepObject", explode: true }) filter?: ItemFilter,
71
+ @query(#{ explode: true }) expand?: string[],
72
+ @query timestamp?: utcDateTime,
73
+ ): string[];
74
+
75
+ @get
76
+ @route("/cursor")
77
+ op cursor(
78
+ @query(#{ style: "deepObject", explode: true })
79
+ page?: {
80
+ size?: integer;
81
+ after?: string;
82
+ before?: string;
83
+ },
84
+ ): string[];
85
+ }
86
+ `)
87
+
88
+ const list = operations.find(
89
+ (operation) => operation.operation.name === 'list',
90
+ )!
91
+ expect(
92
+ Object.fromEntries(
93
+ list.queryParams.map((parameter) => [
94
+ parameter.name,
95
+ parameter.queryCodec,
96
+ ]),
97
+ ),
98
+ ).toMatchObject({
99
+ page: { kind: 'page' },
100
+ sort: { kind: 'sort' },
101
+ filter: { kind: 'deepObject' },
102
+ expand: { kind: 'array', explode: true },
103
+ timestamp: { kind: 'scalar' },
104
+ })
105
+ expect(list.pagination).toBe('page')
106
+
107
+ const cursor = operations.find(
108
+ (operation) => operation.operation.name === 'cursor',
109
+ )!
110
+ expect(cursor.queryParams[0]?.queryCodec).toEqual({
111
+ kind: 'cursorPage',
112
+ })
113
+ expect(cursor.pagination).toBe('cursor')
114
+ })
115
+
116
+ it('retains optional body and media-type metadata', async () => {
117
+ const operations = await compileOperations(`
118
+ @service namespace Test;
119
+
120
+ model Request {
121
+ value: string;
122
+ }
123
+
124
+ model Response {
125
+ value: string;
126
+ }
127
+
128
+ @route("/items")
129
+ interface Operations {
130
+ @post
131
+ op create(@body body?: Request): Response;
132
+ }
133
+ `)
134
+
135
+ expect(operations).toHaveLength(1)
136
+ expect(operations[0]).toMatchObject({
137
+ bodyOptional: true,
138
+ requestContentType: 'application/json',
139
+ responseContentType: 'application/json',
140
+ })
141
+ })
142
+
143
+ it('retains shared-route media-type variants as distinct methods', async () => {
144
+ const operations = await compileOperations(`
145
+ @service namespace Test;
146
+
147
+ model Event {
148
+ id: string;
149
+ }
150
+
151
+ model Response {
152
+ accepted: boolean;
153
+ }
154
+
155
+ @route("/events")
156
+ interface Operations {
157
+ @post
158
+ @operationId("ingest-metering-events")
159
+ @sharedRoute
160
+ ingestEvent(
161
+ @header contentType: "application/cloudevents+json",
162
+ @body body: Event,
163
+ ): Response;
164
+
165
+ @post
166
+ @operationId("ingest-metering-events")
167
+ @sharedRoute
168
+ ingestEvents(
169
+ @header contentType: "application/cloudevents-batch+json",
170
+ @body body: Event[],
171
+ ): Response;
172
+
173
+ @post
174
+ @operationId("ingest-metering-events")
175
+ @sharedRoute
176
+ ingestEventsJson(
177
+ @header contentType: "application/json",
178
+ @body body: Event | Event[],
179
+ ): Response;
180
+ }
181
+ `)
182
+
183
+ expect(operations.map((operation) => operation.methodName)).toEqual([
184
+ 'IngestEvent',
185
+ 'IngestEvents',
186
+ 'IngestEventsJSON',
187
+ ])
188
+ expect(operations.map((operation) => operation.requestContentType)).toEqual(
189
+ [
190
+ 'application/cloudevents+json',
191
+ 'application/cloudevents-batch+json',
192
+ 'application/json',
193
+ ],
194
+ )
195
+ expect(operations.map((operation) => operation.body?.kind)).toEqual([
196
+ 'Model',
197
+ 'Model',
198
+ 'Union',
199
+ ])
200
+ })
201
+
202
+ it('rejects operations that cannot be traced to a resource namespace', async () => {
203
+ const program = await compileProgram(`
204
+ @service namespace Test;
205
+
206
+ @route("/items")
207
+ interface Operations {
208
+ @get op list(): string[];
209
+ }
210
+ `)
211
+
212
+ const operations = collectHttpOperations(program)
213
+ expect(() => groupOperations(operations)).toThrow(
214
+ 'cannot place operation Operations.list',
215
+ )
216
+ })
217
+
218
+ it('groups operations by the namespace of their source interface', async () => {
219
+ const program = await compileProgram(`
220
+ namespace Widgets {
221
+ interface Operations {
222
+ @get op list(): string[];
223
+ }
224
+ }
225
+
226
+ @service
227
+ namespace Test {
228
+ @route("/widgets")
229
+ interface Endpoints extends Widgets.Operations {}
230
+ }
231
+ `)
232
+
233
+ const operations = collectHttpOperations(program)
234
+ expect([...groupOperations(operations).keys()]).toEqual(['Widgets'])
235
+ })
236
+
237
+ it('keeps same-named operations in different containers from sharing bodies', async () => {
238
+ const program = await compileProgram(`
239
+ @service namespace Test;
240
+
241
+ model Payload {
242
+ value: string;
243
+ }
244
+
245
+ @route("/widgets")
246
+ interface Widgets {
247
+ @get op list(): string[];
248
+ }
249
+
250
+ @route("/gadgets")
251
+ interface Gadgets {
252
+ @post op list(@body body: Payload): string[];
253
+ }
254
+ `)
255
+
256
+ const overrides = jsonBodyOverrides(program)
257
+ expect(overrides.size).toBe(0)
258
+
259
+ const operations = describeOperations(
260
+ program,
261
+ 'Test',
262
+ collectHttpOperations(program),
263
+ overrides,
264
+ )
265
+ const widgetsList = operations.find(
266
+ (operation) => operation.path === '/widgets',
267
+ )!
268
+ expect(widgetsList.body).toBeUndefined()
269
+ const gadgetsList = operations.find(
270
+ (operation) => operation.path === '/gadgets',
271
+ )!
272
+ expect((gadgetsList.body as Model | undefined)?.name).toBe('Payload')
273
+ })
274
+
275
+ it('attaches the shared-route JSON body to bodyless siblings by qualified key', async () => {
276
+ const program = await compileProgram(`
277
+ @service namespace Test;
278
+
279
+ model QueryRequest {
280
+ value: string;
281
+ }
282
+
283
+ model QueryResult {
284
+ value: string;
285
+ }
286
+
287
+ @route("/query")
288
+ interface Operations {
289
+ @post
290
+ @operationId("query-thing")
291
+ @sharedRoute
292
+ query(@body request: QueryRequest): {
293
+ @header contentType: "application/json";
294
+ @body _: QueryResult;
295
+ };
296
+
297
+ @friendlyName("queryThingCsv")
298
+ @post
299
+ @operationId("query-thing")
300
+ @sharedRoute
301
+ queryCsv(): {
302
+ @header contentType: "text/csv";
303
+ @body _: string;
304
+ };
305
+ }
306
+ `)
307
+
308
+ const overrides = jsonBodyOverrides(program)
309
+ expect([...overrides.keys()]).toEqual(['Test.Operations.queryCsv'])
310
+
311
+ const operations = describeOperations(
312
+ program,
313
+ 'Test',
314
+ collectHttpOperations(program),
315
+ overrides,
316
+ )
317
+ const csv = operations.find(
318
+ (operation) => operation.operation.name === 'queryCsv',
319
+ )!
320
+ expect(csv.requestContentType).toBe('application/json')
321
+ expect((csv.body as Model | undefined)?.name).toBe('QueryRequest')
322
+ })
323
+
324
+ it('rejects multiple 2xx response bodies with different types', async () => {
325
+ await expect(
326
+ compileOperations(`
327
+ @service namespace Test;
328
+
329
+ model Created {
330
+ id: string;
331
+ }
332
+
333
+ model Accepted {
334
+ token: string;
335
+ }
336
+
337
+ @route("/items")
338
+ interface Operations {
339
+ @post op create(): {
340
+ @statusCode _: 201;
341
+ @body body: Created;
342
+ } | {
343
+ @statusCode _: 202;
344
+ @body body: Accepted;
345
+ };
346
+ }
347
+ `),
348
+ ).rejects.toThrow('multiple 2xx response bodies with different types')
349
+ })
350
+
351
+ it('accepts multiple 2xx responses sharing one body type', async () => {
352
+ const operations = await compileOperations(`
353
+ @service namespace Test;
354
+
355
+ model Created {
356
+ id: string;
357
+ }
358
+
359
+ @route("/items")
360
+ interface Operations {
361
+ @post op create(): {
362
+ @statusCode _: 200;
363
+ @body body: Created;
364
+ } | {
365
+ @statusCode _: 201;
366
+ @body body: Created;
367
+ };
368
+ }
369
+ `)
370
+
371
+ expect(operations[0]?.response?.kind).toBe('Model')
372
+ })
373
+
374
+ it('rejects page parameters that match no pagination shape', async () => {
375
+ await expect(
376
+ compileOperations(`
377
+ @service namespace Test;
378
+
379
+ @route("/items")
380
+ interface Operations {
381
+ @get op list(
382
+ @query(#{ style: "deepObject", explode: true })
383
+ page?: {
384
+ size?: integer;
385
+ after?: string;
386
+ tenant?: string;
387
+ },
388
+ ): string[];
389
+ }
390
+ `),
391
+ ).rejects.toThrow('query parameter page on list')
392
+
393
+ await expect(
394
+ compileOperations(`
395
+ @service namespace Test;
396
+
397
+ @route("/items")
398
+ interface Operations {
399
+ @get op list(
400
+ @query(#{ style: "deepObject", explode: true })
401
+ page?: {
402
+ size?: integer;
403
+ },
404
+ ): string[];
405
+ }
406
+ `),
407
+ ).rejects.toThrow('query parameter page on list')
408
+ })
409
+
410
+ it('keeps pagination-shaped models under other names as deep objects', async () => {
411
+ const operations = await compileOperations(`
412
+ @service namespace Test;
413
+
414
+ @route("/items")
415
+ interface Operations {
416
+ @get op list(
417
+ @query(#{ style: "deepObject", explode: true })
418
+ filter?: {
419
+ size?: integer;
420
+ after?: string;
421
+ },
422
+ ): string[];
423
+ }
424
+ `)
425
+
426
+ expect(operations[0]?.queryParams[0]?.queryCodec).toMatchObject({
427
+ kind: 'deepObject',
428
+ })
429
+ expect(operations[0]?.pagination).toBeUndefined()
430
+ })
431
+
432
+ it('rejects caller-controlled headers until a header codec exists', async () => {
433
+ await expect(
434
+ compileOperations(`
435
+ @service namespace Test;
436
+
437
+ @route("/items")
438
+ interface Operations {
439
+ @get
440
+ op get(@header requestId?: string): string;
441
+ }
442
+ `),
443
+ ).rejects.toThrow(
444
+ 'typespec-go: unsupported header parameter request-id on get',
445
+ )
446
+ })
447
+
448
+ it('derives nested service paths from the source namespace', async () => {
449
+ const host = await createTestHost({ libraries: [HttpTestLibrary] })
450
+ const runner = await createTestRunner(host)
451
+ await runner.compile(`
452
+ import "@typespec/http";
453
+ using TypeSpec.Http;
454
+
455
+ namespace Customers.Credits.Grants {
456
+ interface Operations {
457
+ @get op list(): string[];
458
+ }
459
+ }
460
+
461
+ @service
462
+ namespace Test {
463
+ @route("/grants")
464
+ interface Endpoints extends Customers.Credits.Grants.Operations {}
465
+ }
466
+ `)
467
+
468
+ const [operation] = collectHttpOperations(runner.program)
469
+ expect(operationNestPath(operation!, 'Customers')).toEqual([
470
+ 'Credits',
471
+ 'Grants',
472
+ ])
473
+ })
474
+
475
+ it('marks defaulted both-reachable models and their parents as divergent', async () => {
476
+ const host = await createTestHost()
477
+ const runner = await createTestRunner(host)
478
+ await runner.compile(`
479
+ model Child {
480
+ mode: string = "default";
481
+ }
482
+ model Parent {
483
+ child: Child;
484
+ }
485
+ `)
486
+
487
+ const global = runner.program.getGlobalNamespaceType()
488
+ const child = global.models.get('Child')!
489
+ const parent = global.models.get('Parent')!
490
+ const both = new Set([parent, child])
491
+ const divergent = computeDivergentTypes(runner.program, both, both)
492
+
493
+ expect(divergent.has(child)).toBe(true)
494
+ expect(divergent.has(parent)).toBe(true)
495
+ })
496
+
497
+ it('marks optional collection both-reachable models as divergent', async () => {
498
+ const host = await createTestHost()
499
+ const runner = await createTestRunner(host)
500
+ await runner.compile(`
501
+ model Request {
502
+ labels?: Record<string>;
503
+ features?: string[];
504
+ }
505
+ `)
506
+
507
+ const global = runner.program.getGlobalNamespaceType()
508
+ const request = global.models.get('Request')!
509
+ const both = new Set([request])
510
+ const divergent = computeDivergentTypes(runner.program, both, both)
511
+
512
+ expect(divergent.has(request)).toBe(true)
513
+ })
514
+
515
+ it('keeps request-only models out of the divergent set', async () => {
516
+ const host = await createTestHost()
517
+ const runner = await createTestRunner(host)
518
+ await runner.compile(`
519
+ model Request {
520
+ labels?: Record<string>;
521
+ }
522
+ `)
523
+
524
+ const global = runner.program.getGlobalNamespaceType()
525
+ const request = global.models.get('Request')!
526
+ const divergent = computeDivergentTypes(
527
+ runner.program,
528
+ new Set(),
529
+ new Set([request]),
530
+ )
531
+
532
+ expect(divergent.size).toBe(0)
533
+ })
534
+
535
+ it('rejects generated type names that collide with reserved runtime symbols', async () => {
536
+ const host = await createTestHost()
537
+ const runner = await createTestRunner(host)
538
+ await runner.compile(`
539
+ model APIError {
540
+ title: string;
541
+ }
542
+ `)
543
+
544
+ const apiError = runner.program
545
+ .getGlobalNamespaceType()
546
+ .models.get('APIError')!
547
+
548
+ expect(() =>
549
+ validateUniqueTypeNames(runner.program, new Set([apiError])),
550
+ ).toThrow('reserved SDK runtime symbol APIError')
551
+ })
552
+
553
+ it('treats SortQuery as a runtime-backed query helper instead of a generated model', () => {
554
+ expect(isRuntimeBackedTypeName('SortQuery', 'Model')).toBe(true)
555
+ })
556
+
557
+ it('strips configured Go type-name prefixes only when unambiguous', async () => {
558
+ const host = await createTestHost()
559
+ const runner = await createTestRunner(host)
560
+ await runner.compile(`
561
+ model BillingWidget {
562
+ value: string;
563
+ }
564
+
565
+ model MeteringPlan {
566
+ value: string;
567
+ }
568
+
569
+ model Plan {
570
+ value: string;
571
+ }
572
+ `)
573
+
574
+ const global = runner.program.getGlobalNamespaceType()
575
+ const billingWidget = global.models.get('BillingWidget')!
576
+ const meteringPlan = global.models.get('MeteringPlan')!
577
+ const plan = global.models.get('Plan')!
578
+
579
+ configureGoTypeNames(runner.program, ['Billing', 'Metering'])
580
+ resolveGoTypeNames(runner.program, [billingWidget, meteringPlan, plan])
581
+
582
+ expect(optionalTypeName(runner.program, billingWidget)).toBe('Widget')
583
+ expect(optionalTypeName(runner.program, meteringPlan)).toBe('MeteringPlan')
584
+ expect(optionalTypeName(runner.program, plan)).toBe('Plan')
585
+ })
586
+ })
api/spec/packages/typespec-go/test/projections.test.ts ADDED
@@ -0,0 +1,504 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Model, Program, Type, Union } from '@typespec/compiler'
2
+ import { createTestHost, createTestRunner } from '@typespec/compiler/testing'
3
+ import { HttpTestLibrary } from '@typespec/http/testing'
4
+ import { OpenAPITestLibrary } from '@typespec/openapi/testing'
5
+ import { describe, expect, it } from 'vitest'
6
+ import {
7
+ configureGoProjections,
8
+ configureGoTypeNames,
9
+ goFields,
10
+ goType,
11
+ optionalTypeName,
12
+ resolveGoTypeNames,
13
+ setSyntheticTypeNames,
14
+ type GoProjections,
15
+ } from '../dist/go-types.js'
16
+ import { collectHttpOperations, jsonBodyOverrides } from '../dist/operations.js'
17
+ import { groupOperations } from '../dist/grouping.js'
18
+ import {
19
+ computeDivergentTypes,
20
+ computeReachability,
21
+ computeStructuralAliases,
22
+ planDeclarations,
23
+ promoteAnonymousModels,
24
+ } from '../dist/projections.js'
25
+ import { renderUnion } from '../dist/components/GoModels.js'
26
+
27
+ interface PlannedProgram {
28
+ program: Program
29
+ projections: GoProjections
30
+ modelTypes: Set<Type>
31
+ namedType: (name: string) => Type
32
+ }
33
+
34
+ /** Mirrors the emitter's planning phase: reachability, name resolution,
35
+ * anonymous-model promotion, divergence, declaration planning, and structural
36
+ * dedupe, then configures the live registry the render components consult. */
37
+ async function planFixture(code: string): Promise<PlannedProgram> {
38
+ const host = await createTestHost({
39
+ libraries: [HttpTestLibrary, OpenAPITestLibrary],
40
+ })
41
+ const runner = await createTestRunner(host)
42
+ await runner.compile(`
43
+ import "@typespec/http";
44
+ import "@typespec/openapi";
45
+ using TypeSpec.Http;
46
+ using TypeSpec.OpenAPI;
47
+ ${code}
48
+ `)
49
+ const program = runner.program
50
+ configureGoTypeNames(program, [])
51
+ const operations = collectHttpOperations(program)
52
+ const groups = groupOperations(operations)
53
+ const bodyOverrides = jsonBodyOverrides(program)
54
+ const reachability = computeReachability(program, groups, bodyOverrides)
55
+ const modelTypes = new Set(
56
+ [...reachability.byResource.values()].flatMap((types) => [...types]),
57
+ )
58
+ resolveGoTypeNames(program, modelTypes)
59
+ setSyntheticTypeNames(program, promoteAnonymousModels(program, modelTypes))
60
+ const divergent = computeDivergentTypes(
61
+ program,
62
+ reachability.readReachable,
63
+ reachability.inputReachable,
64
+ )
65
+ const declarations = planDeclarations(
66
+ program,
67
+ modelTypes,
68
+ reachability.readReachable,
69
+ reachability.inputReachable,
70
+ divergent,
71
+ )
72
+ const aliases = computeStructuralAliases(
73
+ program,
74
+ declarations,
75
+ reachability.readReachable,
76
+ divergent,
77
+ )
78
+ const projections: GoProjections = {
79
+ readReachable: reachability.readReachable,
80
+ inputReachable: reachability.inputReachable,
81
+ divergent,
82
+ aliases,
83
+ declarations,
84
+ }
85
+ configureGoProjections(program, projections)
86
+
87
+ const namedType = (name: string): Type => {
88
+ const match = [...modelTypes].find(
89
+ (type) => optionalTypeName(program, type) === name,
90
+ )
91
+ if (!match) {
92
+ throw new Error(`fixture type ${name} not reachable`)
93
+ }
94
+ return match
95
+ }
96
+
97
+ return { program, projections, modelTypes, namedType }
98
+ }
99
+
100
+ const declarationNames = (projections: GoProjections): string[] =>
101
+ [...projections.declarations.values()]
102
+ .flatMap((declarations) => declarations.map(({ name }) => name))
103
+ .sort()
104
+
105
+ describe('payload-context visibility', () => {
106
+ it('drops create/update-only properties from response-reachable models', async () => {
107
+ const { program, projections, namedType } = await planFixture(`
108
+ model App {
109
+ name: string;
110
+
111
+ @visibility(Lifecycle.Read)
112
+ masked_api_key: string;
113
+
114
+ @visibility(Lifecycle.Create, Lifecycle.Update)
115
+ @secret
116
+ secret_api_key?: string;
117
+ }
118
+
119
+ namespace Apps {
120
+ interface Operations {
121
+ @get op get(): App;
122
+ }
123
+ }
124
+
125
+ @service
126
+ namespace Test {
127
+ @route("/apps")
128
+ interface Endpoints extends Apps.Operations {}
129
+ }
130
+ `)
131
+
132
+ const app = namedType('App') as Model
133
+ expect(projections.declarations.get(app)).toEqual([
134
+ { name: 'App', mode: 'read' },
135
+ ])
136
+ const fieldNames = goFields(program, app).map((field) => field.name)
137
+ expect(fieldNames).toEqual(['Name', 'MaskedAPIKey'])
138
+ // The input rendering of the same model keeps the spec-projected fields.
139
+ const inputNames = goFields(program, app, { mode: 'input' }).map(
140
+ (field) => field.name,
141
+ )
142
+ expect(inputNames).toContain('SecretAPIKey')
143
+ })
144
+ })
145
+
146
+ describe('request-type unification', () => {
147
+ it('emits request-only models once, as the input projection under the natural name', async () => {
148
+ const { program, projections, namedType } = await planFixture(`
149
+ model CreateMeterRequest {
150
+ name: string;
151
+ labels?: Record<string>;
152
+ }
153
+
154
+ model Meter {
155
+ id: string;
156
+ name: string;
157
+ }
158
+
159
+ namespace Meters {
160
+ interface Operations {
161
+ @post op create(@body body: CreateMeterRequest): Meter;
162
+ }
163
+ }
164
+
165
+ @service
166
+ namespace Test {
167
+ @route("/meters")
168
+ interface Endpoints extends Meters.Operations {}
169
+ }
170
+ `)
171
+
172
+ const request = namedType('CreateMeterRequest') as Model
173
+ expect(projections.declarations.get(request)).toEqual([
174
+ { name: 'CreateMeterRequest', mode: 'input' },
175
+ ])
176
+ expect(declarationNames(projections)).not.toContain(
177
+ 'CreateMeterRequestInput',
178
+ )
179
+ // The single emission uses input semantics: the optional collection is
180
+ // pointered so an explicitly empty map survives omitempty.
181
+ const labels = goFields(program, request, { mode: 'input' }).find(
182
+ (field) => field.name === 'Labels',
183
+ )
184
+ expect(labels?.typeText).toBe('*map[string]string')
185
+ })
186
+
187
+ it('keeps distinct read and input declarations for dual-reachable divergent models', async () => {
188
+ const { program, projections, namedType } = await planFixture(`
189
+ model Event {
190
+ id: string;
191
+ specversion: string = "1.0";
192
+ }
193
+
194
+ model IngestedEvent {
195
+ event: Event;
196
+ }
197
+
198
+ namespace Events {
199
+ interface Operations {
200
+ @post op ingest(@body body: Event): void;
201
+ @get op get(): IngestedEvent;
202
+ }
203
+ }
204
+
205
+ @service
206
+ namespace Test {
207
+ @route("/events")
208
+ interface Endpoints extends Events.Operations {}
209
+ }
210
+ `)
211
+
212
+ const event = namedType('Event')
213
+ expect(projections.divergent.has(event)).toBe(true)
214
+ expect(projections.declarations.get(event)).toEqual([
215
+ { name: 'Event', mode: 'read' },
216
+ { name: 'EventInput', mode: 'input' },
217
+ ])
218
+ // Request-side references resolve to the input twin, read side to Event.
219
+ expect(goType(program, event, { mode: 'input' }).type).toBe('EventInput')
220
+ expect(goType(program, event).type).toBe('Event')
221
+ })
222
+
223
+ it('does not diverge dual-reachable models whose only default sits on an optional property', async () => {
224
+ // fieldShape keeps an already-optional property optional in both modes,
225
+ // so an optional-with-default property renders byte-identically and must
226
+ // not spawn a spurious *Input twin.
227
+ const { program, projections, namedType } = await planFixture(`
228
+ model Profile {
229
+ id: string;
230
+ interval?: string = "PT1H";
231
+ }
232
+
233
+ namespace Profiles {
234
+ interface Operations {
235
+ @post op create(@body body: Profile): Profile;
236
+ }
237
+ }
238
+
239
+ @service
240
+ namespace Test {
241
+ @route("/profiles")
242
+ interface Endpoints extends Profiles.Operations {}
243
+ }
244
+ `)
245
+
246
+ const profile = namedType('Profile')
247
+ expect(projections.divergent.has(profile)).toBe(false)
248
+ expect(declarationNames(projections)).not.toContain('ProfileInput')
249
+ expect(goType(program, profile, { mode: 'input' }).type).toBe('Profile')
250
+ })
251
+ })
252
+
253
+ describe('structural dedupe of visibility projections', () => {
254
+ it('collapses identical projection twins onto the canonical types', async () => {
255
+ const { program, projections, namedType } = await planFixture(`
256
+ model Address {
257
+ // Visible in both lifecycles: the Update copy filters nothing away and
258
+ // stays byte-identical to the canonical model.
259
+ @visibility(Lifecycle.Read, Lifecycle.Update)
260
+ country?: string;
261
+ }
262
+
263
+ model Doc {
264
+ address: Address;
265
+ }
266
+
267
+ model UpdateDoc
268
+ is FilterVisibility<Doc, #{ all: #[Lifecycle.Update] }, "Update{name}">;
269
+
270
+ namespace Docs {
271
+ interface Operations {
272
+ @get op get(): Doc;
273
+ @patch(#{ implicitOptionality: false }) op update(@body body: UpdateDoc): Doc;
274
+ }
275
+ }
276
+
277
+ @service
278
+ namespace Test {
279
+ @route("/docs")
280
+ interface Endpoints extends Docs.Operations {}
281
+ }
282
+ `)
283
+
284
+ // The Update copies are byte-identical to the canonical read models, so
285
+ // both the root and the nested reference pair collapse.
286
+ expect(projections.aliases.get('UpdateDoc')).toBe('Doc')
287
+ expect(projections.aliases.get('UpdateAddress')).toBe('Address')
288
+ const names = declarationNames(projections)
289
+ expect(names).not.toContain('UpdateDoc')
290
+ expect(names).not.toContain('UpdateAddress')
291
+
292
+ const updateDoc = namedType('UpdateDoc')
293
+ expect(goType(program, updateDoc, { mode: 'input' }).type).toBe('Doc')
294
+ })
295
+
296
+ it('keeps projection twins whose filtered shape genuinely differs', async () => {
297
+ const { projections } = await planFixture(`
298
+ model Doc {
299
+ name: string;
300
+
301
+ @visibility(Lifecycle.Read)
302
+ etag: string;
303
+ }
304
+
305
+ model UpdateDoc
306
+ is FilterVisibility<Doc, #{ all: #[Lifecycle.Update] }, "Update{name}">;
307
+
308
+ namespace Docs {
309
+ interface Operations {
310
+ @get op get(): Doc;
311
+ @patch(#{ implicitOptionality: false }) op update(@body body: UpdateDoc): Doc;
312
+ }
313
+ }
314
+
315
+ @service
316
+ namespace Test {
317
+ @route("/docs")
318
+ interface Endpoints extends Docs.Operations {}
319
+ }
320
+ `)
321
+
322
+ expect(projections.aliases.size).toBe(0)
323
+ expect(declarationNames(projections)).toContain('UpdateDoc')
324
+ })
325
+ })
326
+
327
+ describe('anonymous model promotion', () => {
328
+ it('promotes anonymous inline models to enclosing-type-plus-field names', async () => {
329
+ const { program, namedType, projections } = await planFixture(`
330
+ model SubscriptionCreate {
331
+ customer: {
332
+ id?: string;
333
+ key?: string;
334
+ };
335
+ }
336
+
337
+ namespace Subscriptions {
338
+ interface Operations {
339
+ @post op create(@body body: SubscriptionCreate): void;
340
+ }
341
+ }
342
+
343
+ @service
344
+ namespace Test {
345
+ @route("/subscriptions")
346
+ interface Endpoints extends Subscriptions.Operations {}
347
+ }
348
+ `)
349
+
350
+ expect(declarationNames(projections)).toContain(
351
+ 'SubscriptionCreateCustomer',
352
+ )
353
+ const create = namedType('SubscriptionCreate') as Model
354
+ const customer = goFields(program, create, { mode: 'input' }).find(
355
+ (field) => field.name === 'Customer',
356
+ )
357
+ expect(customer?.typeText).toBe('SubscriptionCreateCustomer')
358
+ })
359
+
360
+ it('fails loudly when a promoted name collides with an existing type', async () => {
361
+ const host = await createTestHost({
362
+ libraries: [HttpTestLibrary, OpenAPITestLibrary],
363
+ })
364
+ const runner = await createTestRunner(host)
365
+ await runner.compile(`
366
+ import "@typespec/http";
367
+ import "@typespec/openapi";
368
+ using TypeSpec.Http;
369
+ using TypeSpec.OpenAPI;
370
+
371
+ model WidgetOwner {
372
+ name: string;
373
+ }
374
+
375
+ model Widget {
376
+ owner: {
377
+ id: string;
378
+ };
379
+ fallback: WidgetOwner;
380
+ }
381
+
382
+ namespace Widgets {
383
+ interface Operations {
384
+ @get op get(): Widget;
385
+ }
386
+ }
387
+
388
+ @service
389
+ namespace Test {
390
+ @route("/widgets")
391
+ interface Endpoints extends Widgets.Operations {}
392
+ }
393
+ `)
394
+ const program = runner.program
395
+ configureGoTypeNames(program, [])
396
+ const operations = collectHttpOperations(program)
397
+ const reachability = computeReachability(
398
+ program,
399
+ groupOperations(operations),
400
+ jsonBodyOverrides(program),
401
+ )
402
+ const modelTypes = new Set(
403
+ [...reachability.byResource.values()].flatMap((types) => [...types]),
404
+ )
405
+ resolveGoTypeNames(program, modelTypes)
406
+
407
+ expect(() => promoteAnonymousModels(program, modelTypes)).toThrow(
408
+ 'promoted anonymous model name WidgetOwner collides',
409
+ )
410
+ })
411
+ })
412
+
413
+ describe('scalar alias pruning', () => {
414
+ it('plans no declarations for spec scalars and keeps fields on Go primitives', async () => {
415
+ const { program, projections, namedType } = await planFixture(`
416
+ scalar ResourceKey extends string;
417
+
418
+ model Meter {
419
+ key: ResourceKey;
420
+ }
421
+
422
+ namespace Meters {
423
+ interface Operations {
424
+ @get op get(): Meter;
425
+ }
426
+ }
427
+
428
+ @service
429
+ namespace Test {
430
+ @route("/meters")
431
+ interface Endpoints extends Meters.Operations {}
432
+ }
433
+ `)
434
+
435
+ expect(declarationNames(projections)).toEqual(['Meter'])
436
+ const meter = namedType('Meter') as Model
437
+ const key = goFields(program, meter).find((field) => field.name === 'Key')
438
+ expect(key?.typeText).toBe('string')
439
+ })
440
+ })
441
+
442
+ describe('union rendering guards', () => {
443
+ it('throws for named unions with no concrete variants instead of emitting any', async () => {
444
+ const host = await createTestHost({
445
+ libraries: [HttpTestLibrary, OpenAPITestLibrary],
446
+ })
447
+ const runner = await createTestRunner(host)
448
+ await runner.compile(`
449
+ union Broken {
450
+ nothing: null,
451
+ }
452
+ `)
453
+ const program = runner.program
454
+ configureGoTypeNames(program, [])
455
+ const union = program.getGlobalNamespaceType().unions.get('Broken')!
456
+ resolveGoTypeNames(program, [union])
457
+
458
+ expect(() =>
459
+ renderUnion(program, union as Union, { name: 'Broken', mode: 'read' }),
460
+ ).toThrow('union Broken has no concrete variants representable in Go')
461
+ })
462
+ })
463
+
464
+ describe('filter reachability', () => {
465
+ it('does not promote object variants of runtime-backed filter unions', async () => {
466
+ // Runtime-backed filter unions render as static runtime types
467
+ // (StringFilter, ...); their anonymous object variants must not leak into
468
+ // the reachable set and get emitted as dead *Object declarations.
469
+ const { projections } = await planFixture(`
470
+ union StringFieldFilter {
471
+ equals: string,
472
+ object: {
473
+ eq?: string,
474
+ },
475
+ }
476
+
477
+ model ItemFilter {
478
+ name?: StringFieldFilter;
479
+ }
480
+
481
+ model Item {
482
+ id: string;
483
+ }
484
+
485
+ namespace Items {
486
+ interface Operations {
487
+ @get op list(
488
+ @query(#{ style: "deepObject", explode: true }) filter?: ItemFilter,
489
+ ): Item[];
490
+ }
491
+ }
492
+
493
+ @service
494
+ namespace Test {
495
+ @route("/items")
496
+ interface Endpoints extends Items.Operations {}
497
+ }
498
+ `)
499
+
500
+ const names = declarationNames(projections)
501
+ expect(names).toContain('Item')
502
+ expect(names.some((name) => name.endsWith('FieldFilterObject'))).toBe(false)
503
+ })
504
+ })
api/spec/packages/typespec-go/test/resource-render.test.ts ADDED
@@ -0,0 +1,324 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createTestHost, createTestRunner } from '@typespec/compiler/testing'
2
+ import { HttpTestLibrary } from '@typespec/http/testing'
3
+ import { OpenAPITestLibrary } from '@typespec/openapi/testing'
4
+ import { describe, expect, it } from 'vitest'
5
+ import type { Program } from '@typespec/compiler'
6
+ import {
7
+ collectHttpOperations,
8
+ describeOperations,
9
+ type GoOperation,
10
+ } from '../dist/operations.js'
11
+ import {
12
+ deepObjectName,
13
+ isPlainPageEnvelope,
14
+ localName,
15
+ queryScalarValue,
16
+ resolveListParamsNames,
17
+ } from '../dist/components/GoResource.js'
18
+
19
+ async function compileResource(
20
+ code: string,
21
+ ): Promise<{ program: Program; operations: GoOperation[] }> {
22
+ const host = await createTestHost({
23
+ libraries: [HttpTestLibrary, OpenAPITestLibrary],
24
+ })
25
+ const runner = await createTestRunner(host)
26
+ await runner.compile(`
27
+ import "@typespec/http";
28
+ import "@typespec/openapi";
29
+ using TypeSpec.Http;
30
+ using TypeSpec.OpenAPI;
31
+ ${code}
32
+ `)
33
+
34
+ const operations = collectHttpOperations(runner.program)
35
+ return {
36
+ program: runner.program,
37
+ operations: describeOperations(runner.program, 'Test', operations),
38
+ }
39
+ }
40
+
41
+ function operationNamed(operations: GoOperation[], name: string): GoOperation {
42
+ const operation = operations.find(
43
+ (candidate) => candidate.operation.name === name,
44
+ )
45
+ if (!operation) {
46
+ throw new Error(`missing operation ${name} in fixture`)
47
+ }
48
+ return operation
49
+ }
50
+
51
+ const pageFixturePreamble = `
52
+ @service namespace Test;
53
+
54
+ namespace Common {
55
+ model SortQuery {
56
+ by: string;
57
+ order?: "asc" | "desc";
58
+ }
59
+ }
60
+
61
+ model Item {
62
+ id: string;
63
+ }
64
+
65
+ model ItemPageMeta {
66
+ total: int32;
67
+ }
68
+
69
+ model ItemPage {
70
+ data: Item[];
71
+ meta: ItemPageMeta;
72
+ }
73
+ `
74
+
75
+ describe('list params struct naming', () => {
76
+ it('splits shared-element params structs when query shapes differ', async () => {
77
+ const { program, operations } = await compileResource(`
78
+ ${pageFixturePreamble}
79
+
80
+ @route("/items")
81
+ interface Operations {
82
+ @get
83
+ @operationId("list-items")
84
+ op listItems(
85
+ @query(#{ style: "deepObject", explode: true })
86
+ page?: {
87
+ size?: integer;
88
+ number?: integer;
89
+ },
90
+ @query sort?: Common.SortQuery,
91
+ ): ItemPage;
92
+
93
+ @get
94
+ @route("/archived")
95
+ @operationId("list-archived-items")
96
+ op listArchived(
97
+ @query(#{ style: "deepObject", explode: true })
98
+ page?: {
99
+ size?: integer;
100
+ number?: integer;
101
+ },
102
+ ): ItemPage;
103
+ }
104
+ `)
105
+
106
+ const names = resolveListParamsNames(program, operations)
107
+ expect(names.get(operationNamed(operations, 'listItems'))).toBe(
108
+ 'ListItemsParams',
109
+ )
110
+ expect(names.get(operationNamed(operations, 'listArchived'))).toBe(
111
+ 'ListArchivedItemsParams',
112
+ )
113
+
114
+ // The outcome must not depend on which operation is discovered first.
115
+ const reversed = resolveListParamsNames(program, [...operations].reverse())
116
+ expect(reversed.get(operationNamed(operations, 'listItems'))).toBe(
117
+ 'ListItemsParams',
118
+ )
119
+ expect(reversed.get(operationNamed(operations, 'listArchived'))).toBe(
120
+ 'ListArchivedItemsParams',
121
+ )
122
+ })
123
+
124
+ it('shares one element-named params struct when query shapes match', async () => {
125
+ const { program, operations } = await compileResource(`
126
+ ${pageFixturePreamble}
127
+
128
+ @route("/items")
129
+ interface Operations {
130
+ @get
131
+ @operationId("list-items")
132
+ op listItems(
133
+ @query(#{ style: "deepObject", explode: true })
134
+ page?: {
135
+ size?: integer;
136
+ number?: integer;
137
+ },
138
+ ): ItemPage;
139
+
140
+ @get
141
+ @route("/archived")
142
+ @operationId("list-archived-items")
143
+ op listArchived(
144
+ @query(#{ style: "deepObject", explode: true })
145
+ page?: {
146
+ size?: integer;
147
+ number?: integer;
148
+ },
149
+ ): ItemPage;
150
+ }
151
+ `)
152
+
153
+ const names = resolveListParamsNames(program, operations)
154
+ expect(names.get(operationNamed(operations, 'listItems'))).toBe(
155
+ 'ItemListParams',
156
+ )
157
+ expect(names.get(operationNamed(operations, 'listArchived'))).toBe(
158
+ 'ItemListParams',
159
+ )
160
+ })
161
+ })
162
+
163
+ describe('All iterator emission rule', () => {
164
+ it('accepts only the plain {data, meta} page envelope', async () => {
165
+ const { program, operations } = await compileResource(`
166
+ ${pageFixturePreamble}
167
+
168
+ model QueryPage {
169
+ data: Item[];
170
+ meta: ItemPageMeta;
171
+ errors: string[];
172
+ }
173
+
174
+ model StatusItemPage {
175
+ @statusCode _: 200;
176
+ data: Item[];
177
+ meta: ItemPageMeta;
178
+ }
179
+
180
+ @route("/items")
181
+ interface Operations {
182
+ @get
183
+ @operationId("list-plain")
184
+ op listPlain(
185
+ @query(#{ style: "deepObject", explode: true })
186
+ page?: {
187
+ size?: integer;
188
+ number?: integer;
189
+ },
190
+ ): ItemPage;
191
+
192
+ @get
193
+ @route("/query")
194
+ @operationId("list-partial")
195
+ op listPartial(
196
+ @query(#{ style: "deepObject", explode: true })
197
+ page?: {
198
+ size?: integer;
199
+ number?: integer;
200
+ },
201
+ ): QueryPage;
202
+
203
+ @get
204
+ @route("/status")
205
+ @operationId("list-status")
206
+ op listStatus(
207
+ @query(#{ style: "deepObject", explode: true })
208
+ page?: {
209
+ size?: integer;
210
+ number?: integer;
211
+ },
212
+ ): StatusItemPage;
213
+ }
214
+ `)
215
+
216
+ const plain = operationNamed(operations, 'listPlain')
217
+ expect(plain.pagination).toBe('page')
218
+ expect(isPlainPageEnvelope(program, plain.response)).toBe(true)
219
+
220
+ // A paginated response carrying extra fields (partial-failure errors) must
221
+ // not get an All iterator that would silently drop them.
222
+ const partial = operationNamed(operations, 'listPartial')
223
+ expect(partial.pagination).toBe('page')
224
+ expect(isPlainPageEnvelope(program, partial.response)).toBe(false)
225
+
226
+ // HTTP metadata properties (status code, headers) do not count as payload.
227
+ const status = operationNamed(operations, 'listStatus')
228
+ expect(isPlainPageEnvelope(program, status.response)).toBe(true)
229
+
230
+ expect(isPlainPageEnvelope(program, undefined)).toBe(false)
231
+ })
232
+ })
233
+
234
+ describe('path parameter local names', () => {
235
+ it('lowercases whole-acronym parameters', () => {
236
+ expect(localName('id')).toBe('id')
237
+ expect(localName('ulid')).toBe('ulid')
238
+ })
239
+
240
+ it('keeps mixed-word casing intact', () => {
241
+ expect(localName('customerId')).toBe('customerID')
242
+ expect(localName('priceId')).toBe('priceID')
243
+ expect(localName('llmModel')).toBe('llmModel')
244
+ })
245
+
246
+ it('renames parameters colliding with generated method locals', () => {
247
+ expect(localName('request')).toBe('requestParam')
248
+ expect(localName('path')).toBe('pathParam')
249
+ expect(localName('params')).toBe('paramsParam')
250
+ expect(localName('page')).toBe('pageParam')
251
+ expect(localName('s')).toBe('sParam')
252
+ })
253
+ })
254
+
255
+ describe('query rendering helpers', () => {
256
+ it('strips the Params suffix from deep-object type names', async () => {
257
+ const { operations } = await compileResource(`
258
+ @service namespace Test;
259
+
260
+ model ItemFilter {
261
+ key?: string;
262
+ }
263
+
264
+ @route("/items")
265
+ interface Operations {
266
+ @get
267
+ op list(
268
+ @query(#{ style: "deepObject", explode: true }) filter?: ItemFilter,
269
+ @query(#{ style: "deepObject", explode: true }) options?: ItemFilter,
270
+ ): string[];
271
+ }
272
+ `)
273
+
274
+ const list = operationNamed(operations, 'list')
275
+ const filter = list.queryParams.find(
276
+ (parameter) => parameter.name === 'filter',
277
+ )!
278
+ const options = list.queryParams.find(
279
+ (parameter) => parameter.name === 'options',
280
+ )!
281
+
282
+ expect(deepObjectName('ItemListParams', filter)).toBe('ItemFilter')
283
+ expect(deepObjectName('GetCustomerCreditBalanceParams', filter)).toBe(
284
+ 'GetCustomerCreditBalanceFilter',
285
+ )
286
+ expect(deepObjectName('ItemListParams', options)).toBe('ItemListOptions')
287
+ })
288
+
289
+ it('omits no-op query scalar conversions', async () => {
290
+ const { program, operations } = await compileResource(`
291
+ @service namespace Test;
292
+
293
+ enum Color {
294
+ red,
295
+ green,
296
+ }
297
+
298
+ @route("/items")
299
+ interface Operations {
300
+ @get
301
+ op list(
302
+ @query code?: string,
303
+ @query color?: Color,
304
+ ): string[];
305
+ }
306
+ `)
307
+
308
+ const list = operationNamed(operations, 'list')
309
+ const code = list.queryParams.find(
310
+ (parameter) => parameter.name === 'code',
311
+ )!
312
+ const color = list.queryParams.find(
313
+ (parameter) => parameter.name === 'color',
314
+ )!
315
+
316
+ // A Go string needs no conversion (and never doubled parentheses).
317
+ expect(queryScalarValue(program, code.type, '*p.Code')).toBe('*p.Code')
318
+ expect(queryScalarValue(program, code.type, 'value')).toBe('value')
319
+ // Named string-like types still convert, with single parentheses.
320
+ expect(queryScalarValue(program, color.type, '*p.Color')).toBe(
321
+ 'string(*p.Color)',
322
+ )
323
+ })
324
+ })
api/spec/packages/typespec-go/tsconfig.json ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "lib": ["ES2022"],
5
+ "module": "NodeNext",
6
+ "moduleResolution": "NodeNext",
7
+ "jsx": "preserve",
8
+ "jsxImportSource": "@alloy-js/core",
9
+ "noFallthroughCasesInSwitch": true,
10
+ "noUncheckedIndexedAccess": true,
11
+ "noImplicitOverride": true,
12
+ "experimentalDecorators": true,
13
+ "emitDecoratorMetadata": true,
14
+ "libReplacement": false,
15
+ "noImplicitReturns": true,
16
+ "incremental": true,
17
+ "declaration": true,
18
+ "declarationMap": true,
19
+ "sourceMap": true,
20
+ "strict": true,
21
+ "esModuleInterop": true,
22
+ "useDefineForClassFields": true,
23
+ "allowSyntheticDefaultImports": true,
24
+ "forceConsistentCasingInFileNames": true,
25
+ "resolveJsonModule": true,
26
+ "isolatedModules": true,
27
+ "skipLibCheck": true,
28
+ "types": ["node"],
29
+ "rootDir": "./src",
30
+ "outDir": "./dist"
31
+ },
32
+ "include": ["src"],
33
+ "exclude": ["./node_modules", "./dist"]
34
+ }
api/spec/packages/typespec-typescript/.gitignore ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ dist/
2
+ tsconfig.tsbuildinfo
api/spec/packages/typespec-typescript/package.json ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "@openmeter/typespec-typescript",
3
+ "version": "0.0.1",
4
+ "private": true,
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "default": "./dist/index.js"
11
+ }
12
+ },
13
+ "scripts": {
14
+ "build": "alloy build",
15
+ "watch": "alloy build --watch",
16
+ "typecheck": "tsc --noEmit",
17
+ "test": "alloy build && vitest --run",
18
+ "check": "pnpm run typecheck && pnpm run test"
19
+ },
20
+ "dependencies": {
21
+ "@alloy-js/core": "0.23.1",
22
+ "@alloy-js/typescript": "0.23.0",
23
+ "@typespec/compiler": "1.11.0",
24
+ "@typespec/emitter-framework": "0.17.0",
25
+ "@typespec/http": "1.11.0",
26
+ "@typespec/http-client": "0.15.1",
27
+ "@typespec/openapi": "1.11.0",
28
+ "typescript": "6.0.3"
29
+ },
30
+ "devDependencies": {
31
+ "@alloy-js/cli": "0.23.0",
32
+ "@types/node": "25.9.2",
33
+ "zod": "4.4.3"
34
+ },
35
+ "peerDependencies": {
36
+ "zod": ">=4"
37
+ }
38
+ }
api/spec/packages/typespec-typescript/src/ZodOperations.tsx ADDED
@@ -0,0 +1,313 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { type Children, For, refkey } from '@alloy-js/core'
2
+ import {
3
+ ObjectExpression,
4
+ ObjectProperty,
5
+ VarDeclaration,
6
+ } from '@alloy-js/typescript'
7
+ import {
8
+ getFriendlyName,
9
+ type ModelProperty,
10
+ type Operation,
11
+ type Program,
12
+ type Type,
13
+ } from '@typespec/compiler'
14
+ import { $ } from '@typespec/compiler/typekit'
15
+ import { getAllHttpServices } from '@typespec/http'
16
+ import { getExtensions, getOperationId } from '@typespec/openapi'
17
+ import { ZodSchema } from './components/ZodSchema.jsx'
18
+ import { isSuccessStatus } from './http-status.js'
19
+ import { queryCodecForParameter, type QueryCodec } from './query-codecs.js'
20
+ import {
21
+ callPart,
22
+ CoerceContext,
23
+ toCamelCase,
24
+ useWireMode,
25
+ zodMemberExpr,
26
+ } from './utils.jsx'
27
+
28
+ /**
29
+ * Per-operation request and response Zod schemas.
30
+ *
31
+ * The component-level walk emits one schema per data type. This walk adds the
32
+ * HTTP layer on top: for every operation it emits the assembled query- and
33
+ * path-parameter objects (with URL-appropriate coercion), the request body, and
34
+ * the success response body. Bodies reference the shared component schemas via
35
+ * refkey so the reuse the component emitter buys is preserved; only the param
36
+ * objects, which have no standalone component, are built inline.
37
+ */
38
+
39
+ /** Refkey families so per-operation schemas can be cross-referenced if needed. */
40
+ const pathParamsSym = Symbol.for('typespec-typescript.op.pathParams')
41
+ const queryParamsSym = Symbol.for('typespec-typescript.op.queryParams')
42
+ const bodySym = Symbol.for('typespec-typescript.op.body')
43
+ const responseSym = Symbol.for('typespec-typescript.op.response')
44
+
45
+ export interface OperationSchema {
46
+ /**
47
+ * Declared name before prefix stripping (e.g. `CreateCustomerBody`). Joined
48
+ * into the emitter's name pool so strips never collide with these.
49
+ */
50
+ baseName: string
51
+ /** Render the declaration under its final (post-strip) name. */
52
+ render: (name: string) => Children
53
+ }
54
+
55
+ // Customer-visibility markers from the spec's shared/consts.tsp: x-private is
56
+ // "private and should not be exposed to customers", x-internal is "internal and
57
+ // should not be used by customers". Both are emitted but quarantined under the
58
+ // `client.internal.*` sub-client so the audience split stays visible at every
59
+ // call site — the SDK is also how internal consumers call the API, so dropping
60
+ // x-private operations would just push those callers back to hand-rolled HTTP.
61
+ // x-unstable operations stay in the public surface: most of the young v3 API
62
+ // carries that marker, and it flags maturity, not audience.
63
+ function hasExtension(
64
+ program: Program,
65
+ op: Operation,
66
+ key: `x-${string}`,
67
+ ): boolean {
68
+ // The walked operation is an `extends`/`op is` instance; the @extension
69
+ // decorators may live on it or on the source operation it was cloned from,
70
+ // so the whole source chain is consulted.
71
+ for (
72
+ let current: Operation | undefined = op;
73
+ current;
74
+ current = current.sourceOperation
75
+ ) {
76
+ if (getExtensions(program, current).get(key) === true) {
77
+ return true
78
+ }
79
+ }
80
+ return false
81
+ }
82
+
83
+ /**
84
+ * Whether an operation belongs to the `client.internal.*` surface. Internal
85
+ * operations are emitted like any other (funcs, envelope types, zod schemas),
86
+ * but their grouped-client methods live under `client.internal.*` instead of
87
+ * the public sub-clients (see emitter.tsx). x-private implies the internal
88
+ * surface too: it marks a stricter audience than x-internal, so it must never
89
+ * surface publicly, but internal consumers still call it through the SDK.
90
+ */
91
+ export function isInternalOperation(program: Program, op: Operation): boolean {
92
+ return (
93
+ hasExtension(program, op, 'x-internal') ||
94
+ hasExtension(program, op, 'x-private')
95
+ )
96
+ }
97
+
98
+ /**
99
+ * Collect every HTTP operation in the program, de-duplicated by the underlying
100
+ * TypeSpec `Operation` (the same operation surfaces under multiple service
101
+ * namespaces — OpenMeter and MeteringAndBilling — and must not be emitted
102
+ * twice). Operations marked x-internal or x-private are collected like any
103
+ * other and later routed to the `client.internal.*` surface.
104
+ */
105
+ export function collectHttpOperations(
106
+ program: Program,
107
+ includeServices?: string[],
108
+ ): Operation[] {
109
+ const [services] = getAllHttpServices(program)
110
+ const included =
111
+ includeServices && includeServices.length > 0
112
+ ? services.filter((s) => includeServices.includes(s.namespace.name))
113
+ : services
114
+ // The same logical endpoint is declared under multiple service namespaces
115
+ // (OpenMeter and MeteringAndBilling) as distinct `extends` instances, and
116
+ // `@sharedRoute` content-type variants share one id too. De-dupe by the
117
+ // stable operation id and keep the first occurrence so each endpoint emits a
118
+ // single set of schemas.
119
+ const seen = new Set<string>()
120
+ const result: Operation[] = []
121
+ for (const service of included) {
122
+ for (const httpOp of service.operations) {
123
+ const id = operationBaseName(program, httpOp.operation)
124
+ if (seen.has(id)) {
125
+ continue
126
+ }
127
+ seen.add(id)
128
+ result.push(httpOp.operation)
129
+ }
130
+ }
131
+ return result
132
+ }
133
+
134
+ /**
135
+ * `create-customer` / `Foo_bar` -> `CreateCustomer` / `FooBar`.
136
+ *
137
+ * An operation-level `@friendlyName` takes precedence over the operation id:
138
+ * `@sharedRoute` content-type variants share one operation id (one OpenAPI
139
+ * operation), and a friendly name is how the spec marks a variant that must
140
+ * surface as its own SDK operation (e.g. `queryMeterCsv`) instead of being
141
+ * collapsed into its JSON sibling.
142
+ */
143
+ export function operationBaseName(program: Program, op: Operation): string {
144
+ const id =
145
+ getFriendlyName(program, op) || getOperationId(program, op) || op.name
146
+ return id
147
+ .split(/[-_/\s]+/)
148
+ .filter(Boolean)
149
+ .map((part: string) => part.charAt(0).toUpperCase() + part.slice(1))
150
+ .join('')
151
+ }
152
+
153
+ interface ParamLeaf {
154
+ name: string
155
+ prop: ModelProperty
156
+ codec?: QueryCodec
157
+ }
158
+
159
+ function paramObject(
160
+ params: ParamLeaf[],
161
+ camelizeInCamelPass: boolean,
162
+ ): Children {
163
+ // `p.name` is the HTTP-binding wire name. Camelize it only for the public
164
+ // (camel) pass; the wire pass (emitted under WireModeContext, see
165
+ // emitter.tsx's `…Wire` re-render) must keep the raw wire name so a
166
+ // `*QueryParamsWire` schema actually matches the querystring sent on the
167
+ // wire — otherwise it silently describes the wrong (camelCase) shape.
168
+ const wire = useWireMode()
169
+ const camelize = camelizeInCamelPass && !wire
170
+ return (
171
+ <CoerceContext.Provider value={true}>
172
+ {zodMemberExpr(
173
+ callPart(
174
+ 'object',
175
+ <ObjectExpression>
176
+ <For each={params} comma hardline enderPunctuation>
177
+ {(p) => (
178
+ <ObjectProperty name={camelize ? toCamelCase(p.name) : p.name}>
179
+ {wire && p.codec ? (
180
+ <CoerceContext.Provider value={false}>
181
+ <ZodSchema
182
+ type={p.prop}
183
+ valueType={p.codec.wireType}
184
+ nested
185
+ />
186
+ </CoerceContext.Provider>
187
+ ) : (
188
+ <ZodSchema type={p.prop} nested />
189
+ )}
190
+ </ObjectProperty>
191
+ )}
192
+ </For>
193
+ </ObjectExpression>,
194
+ ),
195
+ )}
196
+ </CoerceContext.Provider>
197
+ )
198
+ }
199
+
200
+ /**
201
+ * Build the request/response schema declarations for a single operation.
202
+ * Returns the renderable declarations along with the names they declare so the
203
+ * caller can run them through prefix-stripping/name-policy resolution.
204
+ */
205
+ export function operationSchemas(
206
+ program: Program,
207
+ op: Operation,
208
+ bodyOverrides: Map<string, Type>,
209
+ ): OperationSchema[] {
210
+ const tk = $(program)
211
+ const httpOp = tk.httpOperation.get(op)
212
+ const base = operationBaseName(program, op)
213
+ const out: OperationSchema[] = []
214
+
215
+ const pathParams: ParamLeaf[] = []
216
+ const queryParams: ParamLeaf[] = []
217
+ for (const param of httpOp.parameters.parameters) {
218
+ if (param.type === 'path') {
219
+ pathParams.push({ name: param.name, prop: param.param })
220
+ } else if (param.type === 'query') {
221
+ queryParams.push({
222
+ name: param.name,
223
+ prop: param.param,
224
+ codec: queryCodecForParameter(program, param.name),
225
+ })
226
+ }
227
+ // headers are transport metadata; intentionally skipped.
228
+ }
229
+
230
+ if (pathParams.length > 0) {
231
+ out.push({
232
+ baseName: `${base}PathParams`,
233
+ render: (name) => (
234
+ <VarDeclaration export name={name} refkey={refkey(op, pathParamsSym)}>
235
+ {paramObject(pathParams, false)}
236
+ </VarDeclaration>
237
+ ),
238
+ })
239
+ }
240
+
241
+ if (queryParams.length > 0) {
242
+ out.push({
243
+ baseName: `${base}QueryParams`,
244
+ render: (name) => (
245
+ <VarDeclaration export name={name} refkey={refkey(op, queryParamsSym)}>
246
+ {paramObject(queryParams, true)}
247
+ </VarDeclaration>
248
+ ),
249
+ })
250
+ }
251
+
252
+ // The body the func actually sends: a shared-route JSON override (e.g. the
253
+ // single-or-batch ingest union, or a response-only variant like queryMeterCsv
254
+ // whose declared op omits the body) when present, else the op's own body. The
255
+ // boundary mapper walks this schema, so it must match the wire shape.
256
+ const bodyType = bodyOverrides.get(base) ?? httpOp.parameters.body?.type
257
+ if (bodyType) {
258
+ out.push({
259
+ baseName: `${base}Body`,
260
+ render: (name) => (
261
+ <VarDeclaration export name={name} refkey={refkey(op, bodySym)}>
262
+ {bodySchemaRef(program, bodyType)}
263
+ </VarDeclaration>
264
+ ),
265
+ })
266
+ }
267
+
268
+ const responseType = successBodyType(program, httpOp)
269
+ if (responseType) {
270
+ out.push({
271
+ baseName: `${base}Response`,
272
+ render: (name) => (
273
+ <VarDeclaration export name={name} refkey={refkey(op, responseSym)}>
274
+ {bodySchemaRef(program, responseType)}
275
+ </VarDeclaration>
276
+ ),
277
+ })
278
+ }
279
+
280
+ return out
281
+ }
282
+
283
+ /**
284
+ * Reference the component schema for a body type, or inline it when it is an
285
+ * anonymous/non-declaration type with no standalone component.
286
+ */
287
+ function bodySchemaRef(_program: Program, type: Type): Children {
288
+ // `nested` ZodSchema emits a reference (refkey member expression) for
289
+ // declaration types and inlines anonymous ones, matching the component walk.
290
+ return <ZodSchema type={type} nested />
291
+ }
292
+
293
+ /**
294
+ * The body type of the first 2xx response that carries one. Error responses are
295
+ * available as their own component schemas (e.g. `badRequest`); the per-op
296
+ * `Response` schema models the success payload a caller validates.
297
+ */
298
+ function successBodyType(
299
+ program: Program,
300
+ httpOp: ReturnType<ReturnType<typeof $>['httpOperation']['get']>,
301
+ ): Type | undefined {
302
+ for (const response of httpOp.responses) {
303
+ if (!isSuccessStatus(response.statusCodes)) {
304
+ continue
305
+ }
306
+ for (const content of response.responses) {
307
+ if (content.body) {
308
+ return content.body.type
309
+ }
310
+ }
311
+ }
312
+ return undefined
313
+ }
api/spec/packages/typespec-typescript/src/casing-gate.ts ADDED
@@ -0,0 +1,250 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ type Model,
3
+ type Operation,
4
+ type Program,
5
+ resolveEncodedName,
6
+ type Type,
7
+ type Union,
8
+ } from '@typespec/compiler'
9
+ import { $ } from '@typespec/compiler/typekit'
10
+ import '@typespec/http/experimental/typekit'
11
+ import { isCasingDerivable, toCamelCase } from './casing.js'
12
+ import { isSuccessStatus } from './http-status.js'
13
+ import { bodyProperties, emitsAsIntersection } from './utils.jsx'
14
+
15
+ /**
16
+ * The JSON wire name of a body property: its `@encodedName("application/json", …)`
17
+ * when present, otherwise its declared name. This is the same source the OpenAPI
18
+ * emitter uses, so the gate measures the public→snake transform against the real
19
+ * wire contract rather than against the (camelized) emitted key.
20
+ */
21
+ function wireName(program: Program, prop: Type & { name: string }): string {
22
+ return resolveEncodedName(program, prop, 'application/json')
23
+ }
24
+
25
+ /**
26
+ * Fails the build when an emitted wire key is not recoverable from its public
27
+ * (camelized) form by the deterministic casing rule. The boundary mapper derives
28
+ * every wire key it does not carry an explicit name for via `toSnakeCase`, so a
29
+ * non-derivable name would silently ship a wrong key; this gate turns that into a
30
+ * codegen error. Covers body property names, query parameter names, and the
31
+ * discriminator/envelope keys of discriminated unions — every key the mapper or
32
+ * the URL serializer rewrites.
33
+ */
34
+ export function assertCasingDerivable(
35
+ program: Program,
36
+ models: Model[],
37
+ operations: Operation[],
38
+ ): void {
39
+ const tk = $(program)
40
+ const violations: string[] = []
41
+ const check = (where: string, name: string): void => {
42
+ if (!isCasingDerivable(name)) {
43
+ violations.push(`${where}: '${name}' is not snake↔camel derivable`)
44
+ }
45
+ }
46
+
47
+ // Two distinct wire names in the same object can camelize to the same public
48
+ // key (e.g. `foo_bar` and `fooBar`); each individually round-trips fine, so
49
+ // `check` above never catches it, but the emitted model/query type would
50
+ // silently duplicate or shadow one side. Fail the build instead.
51
+ const collisions: string[] = []
52
+ const checkNoCollisions = (where: string, wireNames: string[]): void => {
53
+ const byCamel = new Map<string, Set<string>>()
54
+ for (const name of wireNames) {
55
+ const camel = toCamelCase(name)
56
+ const group = byCamel.get(camel) ?? new Set<string>()
57
+ group.add(name)
58
+ byCamel.set(camel, group)
59
+ }
60
+ for (const [camel, names] of byCamel) {
61
+ if (names.size > 1) {
62
+ collisions.push(
63
+ `${where}: ${[...names].join(', ')} all camelize to '${camel}'`,
64
+ )
65
+ }
66
+ }
67
+ }
68
+
69
+ for (const model of models) {
70
+ const wireNames: string[] = []
71
+ for (const prop of bodyProperties(program, model)) {
72
+ const name = wireName(program, prop as Type & { name: string })
73
+ check(`${model.name}.${prop.name}`, name)
74
+ wireNames.push(name)
75
+ }
76
+ checkNoCollisions(model.name || '<anonymous model>', wireNames)
77
+ }
78
+
79
+ for (const op of operations) {
80
+ const httpOp = tk.httpOperation.get(op)
81
+ const queryNames: string[] = []
82
+ for (const param of httpOp.parameters.parameters) {
83
+ if (param.type === 'query') {
84
+ check(`${op.name} query`, param.name)
85
+ queryNames.push(param.name)
86
+ }
87
+ }
88
+ checkNoCollisions(`${op.name} query`, queryNames)
89
+ }
90
+
91
+ if (collisions.length > 0) {
92
+ throw new Error(
93
+ `camelCase SDK: ${collisions.length} wire key group(s) collapse onto the ` +
94
+ `same camelCase name. Rename one side or add an explicit override.\n ${collisions.join('\n ')}`,
95
+ )
96
+ }
97
+
98
+ // Unions the boundary mapper actually walks: those reachable from a request body
99
+ // or a success response. Error-envelope unions (e.g. `InvalidParameter` via
100
+ // `badRequest`) are excluded — they are consumed by `to-error.ts`, never mapped.
101
+ const { unions: mappedUnions, models: mappedModels } = mappedReachableTypes(
102
+ program,
103
+ operations,
104
+ )
105
+
106
+ // Models the mapper would walk that emit as `z.intersection(...)` (a record
107
+ // spread combined with named fields): the walker's object/record branches
108
+ // dispatch on `def.type`, and zod has no `"intersection"` case in that
109
+ // dispatch, so such a schema would silently pass through untransformed —
110
+ // the record side keeps its wire casing, the named-field side keeps its
111
+ // wire casing too, and nothing gets camelized. Every intersection model
112
+ // today (`baseError`/`badRequest`) is error-envelope-only and bypasses the
113
+ // mapper entirely, so this fails the build the moment that stops being
114
+ // true rather than letting a future model silently ship the wrong casing.
115
+ const intersectionModels: string[] = []
116
+ for (const model of mappedModels) {
117
+ if (emitsAsIntersection(program, model)) {
118
+ intersectionModels.push(model.name || '<anonymous model>')
119
+ }
120
+ }
121
+ if (intersectionModels.length > 0) {
122
+ throw new Error(
123
+ `camelCase SDK: ${intersectionModels.length} model(s) reachable from a ` +
124
+ `request body or success response emit as z.intersection(...), which the ` +
125
+ `wire mapper cannot walk (no case for it). Restructure the model to avoid ` +
126
+ `combining a record indexer with named properties, or extend the mapper's ` +
127
+ `walk() with an intersection branch.\n ${intersectionModels.join('\n ')}`,
128
+ )
129
+ }
130
+
131
+ const ambiguousUnions: string[] = []
132
+ // Iterates `mappedUnions` (every union reachable from an operation's request/
133
+ // response body, named or inline) rather than only namespace-owned unions, so
134
+ // an inline union in a model field still reaches the ambiguous-union and
135
+ // discriminator/envelope casing checks below.
136
+ for (const union of mappedUnions) {
137
+ const discriminated = tk.union.getDiscriminatedUnion(union)
138
+ if (!discriminated) {
139
+ // A non-discriminated union of two or more object variants has no key the
140
+ // mapper can use to pick a variant; it would have to guess from the data's
141
+ // key set at runtime. The mapper deliberately does not — so fail the build,
142
+ // forcing the union to be `@discriminated` (scalar-vs-object unions are fine,
143
+ // the mapper distinguishes those by JS type).
144
+ if (objectVariantCount(program, union) >= 2) {
145
+ ambiguousUnions.push(union.name ?? '<anonymous union>')
146
+ }
147
+ continue
148
+ }
149
+ check(
150
+ `${union.name ?? 'union'} discriminator`,
151
+ discriminated.options.discriminatorPropertyName,
152
+ )
153
+ if (discriminated.options.envelope === 'object') {
154
+ check(
155
+ `${union.name ?? 'union'} envelope`,
156
+ discriminated.options.envelopePropertyName,
157
+ )
158
+ }
159
+ }
160
+
161
+ if (ambiguousUnions.length > 0) {
162
+ throw new Error(
163
+ `camelCase SDK: ${ambiguousUnions.length} non-discriminated union(s) with ` +
164
+ `multiple object variants cannot be mapped (the wire mapper cannot pick a ` +
165
+ `variant). Add @discriminated.\n ${ambiguousUnions.join('\n ')}`,
166
+ )
167
+ }
168
+
169
+ if (violations.length > 0) {
170
+ throw new Error(
171
+ `camelCase SDK: ${violations.length} wire key(s) are not casing-derivable. ` +
172
+ `Add an @encodedName or an explicit override.\n ${violations.join('\n ')}`,
173
+ )
174
+ }
175
+ }
176
+
177
+ /** The number of a union's variants whose value is an object/model type. */
178
+ function objectVariantCount(program: Program, union: Union): number {
179
+ const tk = $(program)
180
+ let count = 0
181
+ for (const variant of union.variants.values()) {
182
+ const type = variant.type
183
+ if (type.kind === 'Model' && !tk.array.is(type) && !tk.record.is(type)) {
184
+ count++
185
+ }
186
+ }
187
+ return count
188
+ }
189
+
190
+ /**
191
+ * The unions and models the boundary mapper walks: those in the transitive closure
192
+ * of every operation's request body and success-response body. Error responses are
193
+ * excluded (their bodies are read by the error path, not mapped).
194
+ */
195
+ function mappedReachableTypes(
196
+ program: Program,
197
+ operations: Operation[],
198
+ ): { unions: Set<Union>; models: Set<Model> } {
199
+ const tk = $(program)
200
+ const unions = new Set<Union>()
201
+ const models = new Set<Model>()
202
+ const seen = new Set<Type>()
203
+ const visit = (type: Type | undefined): void => {
204
+ if (!type || seen.has(type)) {
205
+ return
206
+ }
207
+ seen.add(type)
208
+ switch (type.kind) {
209
+ case 'Union':
210
+ unions.add(type)
211
+ for (const variant of type.variants.values()) {
212
+ visit(variant.type)
213
+ }
214
+ break
215
+ case 'Model':
216
+ models.add(type)
217
+ if (type.indexer) {
218
+ visit(type.indexer.value)
219
+ }
220
+ if (type.baseModel) {
221
+ visit(type.baseModel)
222
+ }
223
+ for (const prop of type.properties.values()) {
224
+ visit(prop.type)
225
+ }
226
+ break
227
+ case 'Tuple':
228
+ for (const value of type.values) {
229
+ visit(value)
230
+ }
231
+ break
232
+ default:
233
+ break
234
+ }
235
+ }
236
+
237
+ for (const op of operations) {
238
+ const httpOp = tk.httpOperation.get(op)
239
+ visit(httpOp.parameters.body?.type)
240
+ for (const response of httpOp.responses) {
241
+ if (!isSuccessStatus(response.statusCodes)) {
242
+ continue
243
+ }
244
+ for (const content of response.responses) {
245
+ visit(content.body?.type)
246
+ }
247
+ }
248
+ }
249
+ return { unions, models }
250
+ }
api/spec/packages/typespec-typescript/src/casing.ts ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export function toCamelCase(name: string): string {
2
+ return name.replace(/_([a-z0-9])/g, (_m, c: string) => c.toUpperCase())
3
+ }
4
+
5
+ export function toSnakeCase(name: string): string {
6
+ return name.replace(/([A-Z])/g, (_m, c: string) => `_${c.toLowerCase()}`)
7
+ }
8
+
9
+ /**
10
+ * True when a name survives the camel→snake→camel round-trip, i.e. its wire form
11
+ * is recoverable from its public (camelized) form by {@link toSnakeCase} alone.
12
+ * The boundary mapper relies on this for every key it does not carry an explicit
13
+ * wire name for; the codegen gate asserts it over every emitted wire key so a
14
+ * non-derivable name fails the build instead of silently shipping a wrong key.
15
+ */
16
+ export function isCasingDerivable(wireName: string): boolean {
17
+ return toSnakeCase(toCamelCase(wireName)) === wireName
18
+ }
api/spec/packages/typespec-typescript/src/components/ZodCustomTypeComponent.tsx ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Children, ComponentDefinition } from '@alloy-js/core'
2
+ import type { ModelProperty, Type } from '@typespec/compiler'
3
+ import { useTsp } from '@typespec/emitter-framework'
4
+ import {
5
+ getEmitOptionsForType,
6
+ getEmitOptionsForTypeKind,
7
+ useZodOptions,
8
+ } from '../context/zod-options.js'
9
+ import { zodBaseSchemaParts } from '../zodBaseSchema.jsx'
10
+ import { zodConstraintsParts } from '../zodConstraintsParts.jsx'
11
+ import { zodDescriptionParts } from '../zodDescriptionParts.jsx'
12
+ import { zodMemberParts } from '../zodMemberParts.jsx'
13
+
14
+ export interface ZodCustomTypeComponentCommonProps<T extends Type> {
15
+ type: T
16
+ children: Children
17
+ }
18
+
19
+ export interface ZodCustomTypeComponentDeclarationProps<
20
+ T extends Type,
21
+ // biome-ignore lint/suspicious/noExplicitAny: matches @alloy-js/core ComponentDefinition surface
22
+ U extends ComponentDefinition<any>,
23
+ > extends ZodCustomTypeComponentCommonProps<T> {
24
+ declare: true
25
+ declarationProps: U extends ComponentDefinition<infer P> ? P : never
26
+ Declaration: U
27
+ }
28
+
29
+ export interface ZodCustomTypeComponentReferenceProps<
30
+ T extends Type,
31
+ > extends ZodCustomTypeComponentCommonProps<T> {
32
+ reference: true
33
+ member?: ModelProperty
34
+ }
35
+
36
+ export type ZodCustomTypeComponentProps<
37
+ T extends Type,
38
+ // biome-ignore lint/suspicious/noExplicitAny: matches @alloy-js/core ComponentDefinition surface
39
+ U extends ComponentDefinition<any>,
40
+ > =
41
+ | ZodCustomTypeComponentDeclarationProps<T, U>
42
+ | ZodCustomTypeComponentReferenceProps<T>
43
+
44
+ export function ZodCustomTypeComponent<
45
+ T extends Type,
46
+ // biome-ignore lint/suspicious/noExplicitAny: matches @alloy-js/core ComponentDefinition surface
47
+ U extends ComponentDefinition<any>,
48
+ >(props: ZodCustomTypeComponentProps<T, U>) {
49
+ const options = useZodOptions()
50
+ const { $ } = useTsp()
51
+ const descriptor =
52
+ getEmitOptionsForType($.program, props.type, options.customEmit) ??
53
+ getEmitOptionsForTypeKind($.program, props.type.kind, options.customEmit)
54
+
55
+ if (!descriptor) {
56
+ return <>{props.children}</>
57
+ }
58
+
59
+ if ('declare' in props && props.declare && descriptor.declare) {
60
+ const CustomComponent = descriptor.declare
61
+ return (
62
+ <CustomComponent
63
+ type={props.type}
64
+ default={props.children}
65
+ baseSchemaParts={() => zodBaseSchemaParts(props.type)}
66
+ constraintParts={() => zodConstraintsParts(props.type)}
67
+ descriptionParts={() => zodDescriptionParts(props.type)}
68
+ declarationProps={props.declarationProps}
69
+ Declaration={props.Declaration}
70
+ />
71
+ )
72
+ }
73
+
74
+ if ('reference' in props && props.reference && descriptor.reference) {
75
+ const CustomComponent = descriptor.reference
76
+ return (
77
+ <CustomComponent
78
+ type={props.type}
79
+ member={props.member}
80
+ default={props.children}
81
+ baseSchemaParts={() => zodBaseSchemaParts(props.member ?? props.type)}
82
+ constraintParts={() => zodConstraintsParts(props.type, props.member)}
83
+ descriptionParts={() => zodDescriptionParts(props.type, props.member)}
84
+ memberParts={() => zodMemberParts(props.member)}
85
+ />
86
+ )
87
+ }
88
+
89
+ return <>{props.children}</>
90
+ }
api/spec/packages/typespec-typescript/src/components/ZodOptions.tsx ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Children } from '@alloy-js/core'
2
+ import {
3
+ type ZodCustomEmitOptions,
4
+ ZodOptionsContext,
5
+ } from '../context/zod-options.js'
6
+
7
+ export interface ZodOptionsProps {
8
+ /**
9
+ * Provide custom component for rendering a specific TypeSpec type.
10
+ */
11
+ customEmit: ZodCustomEmitOptions
12
+ children: Children
13
+ }
14
+
15
+ /**
16
+ * Set ZodOptions for the children of this component.
17
+ */
18
+ export function ZodOptions(props: ZodOptionsProps) {
19
+ return (
20
+ <ZodOptionsContext.Provider value={{ customEmit: props.customEmit }}>
21
+ {props.children}
22
+ </ZodOptionsContext.Provider>
23
+ )
24
+ }
api/spec/packages/typespec-typescript/src/components/ZodSchema.tsx ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { type Children, refkey } from '@alloy-js/core'
2
+ import { MemberExpression } from '@alloy-js/typescript'
3
+ import type { Type } from '@typespec/compiler'
4
+ import { useTsp } from '@typespec/emitter-framework'
5
+ import { activeRefkeySym, shouldReference, useWireMode } from '../utils.jsx'
6
+ import { zodBaseSchemaParts } from '../zodBaseSchema.jsx'
7
+ import { zodConstraintsParts } from '../zodConstraintsParts.jsx'
8
+ import { zodDescriptionParts } from '../zodDescriptionParts.jsx'
9
+ import { zodMemberParts } from '../zodMemberParts.jsx'
10
+ import { ZodCustomTypeComponent } from './ZodCustomTypeComponent.jsx'
11
+
12
+ export interface ZodSchemaProps {
13
+ readonly type: Type
14
+ /** Overrides the property's value type while preserving its member metadata. */
15
+ readonly valueType?: Type
16
+ readonly nested?: boolean
17
+ }
18
+
19
+ /**
20
+ * Component that translates a TypeSpec type into the Zod type.
21
+ */
22
+ export function ZodSchema(props: ZodSchemaProps): Children {
23
+ const { $ } = useTsp()
24
+ const rkSym = activeRefkeySym(useWireMode())
25
+
26
+ if (!props.nested) {
27
+ return (
28
+ <MemberExpression>
29
+ {zodBaseSchemaParts(props.type)}
30
+ {zodConstraintsParts(props.type)}
31
+ {zodDescriptionParts(props.type)}
32
+ </MemberExpression>
33
+ )
34
+ }
35
+
36
+ const { member, type } = $.modelProperty.is(props.type)
37
+ ? { member: props.type, type: props.type.type }
38
+ : { type: props.type, member: undefined }
39
+ const valueType = props.valueType ?? type
40
+
41
+ if (shouldReference($.program, valueType)) {
42
+ return (
43
+ <ZodCustomTypeComponent type={valueType} member={member} reference>
44
+ <MemberExpression>
45
+ <MemberExpression.Part refkey={refkey(valueType, rkSym)} />
46
+ {zodMemberParts(member)}
47
+ </MemberExpression>
48
+ </ZodCustomTypeComponent>
49
+ )
50
+ }
51
+
52
+ return (
53
+ <ZodCustomTypeComponent type={valueType} member={member} reference>
54
+ <MemberExpression>
55
+ {zodBaseSchemaParts(valueType)}
56
+ {zodConstraintsParts(valueType, member)}
57
+ {zodMemberParts(member)}
58
+ {zodDescriptionParts(valueType, member)}
59
+ </MemberExpression>
60
+ </ZodCustomTypeComponent>
61
+ )
62
+ }
api/spec/packages/typespec-typescript/src/components/ZodSchemaDeclaration.tsx ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as ay from '@alloy-js/core'
2
+ import * as ts from '@alloy-js/typescript'
3
+ import { getFriendlyName } from '@typespec/compiler'
4
+ import { useTsp } from '@typespec/emitter-framework'
5
+ import {
6
+ activeRefkeySym,
7
+ DeclaringTypeContext,
8
+ useWireMode,
9
+ } from '../utils.jsx'
10
+ import { ZodCustomTypeComponent } from './ZodCustomTypeComponent.jsx'
11
+ import { ZodSchema, type ZodSchemaProps } from './ZodSchema.jsx'
12
+
13
+ interface ZodSchemaDeclarationProps
14
+ extends
15
+ Omit<ts.VarDeclarationProps, 'type' | 'name' | 'value' | 'kind'>,
16
+ ZodSchemaProps {
17
+ readonly name?: string
18
+ }
19
+
20
+ /**
21
+ * Declare a Zod schema.
22
+ */
23
+ export function ZodSchemaDeclaration(props: ZodSchemaDeclarationProps) {
24
+ const { $ } = useTsp()
25
+ const internalRk = ay.refkey(props.type, activeRefkeySym(useWireMode()))
26
+ const [zodSchemaProps, varDeclProps] = ay.splitProps(props, [
27
+ 'type',
28
+ 'nested',
29
+ ]) as [ZodSchemaDeclarationProps, ts.VarDeclarationProps]
30
+
31
+ const refkeys = [props.refkey ?? []].flat()
32
+ refkeys.push(internalRk)
33
+ // Prefer `@friendlyName` over the raw template name so instantiations like
34
+ // `CreateRequest<Plan>` become `createPlanRequest` instead of
35
+ // `createRequest_2`. Mirrors the TS SDK emitter and the OpenAPI emitter,
36
+ // which already honor the friendly name. References resolve via refkey, so
37
+ // overriding the declaration name keeps call sites consistent.
38
+ const friendlyName = getFriendlyName($.program, props.type)
39
+ const newProps = ay.mergeProps(varDeclProps, {
40
+ refkey: refkeys,
41
+ name:
42
+ props.name ||
43
+ friendlyName ||
44
+ ('name' in props.type &&
45
+ typeof props.type.name === 'string' &&
46
+ props.type.name) ||
47
+ props.type.kind,
48
+ })
49
+
50
+ return (
51
+ <DeclaringTypeContext.Provider value={props.type}>
52
+ <ZodCustomTypeComponent
53
+ declare
54
+ type={props.type}
55
+ Declaration={ts.VarDeclaration}
56
+ declarationProps={newProps}
57
+ >
58
+ <ts.VarDeclaration {...newProps}>
59
+ <ZodSchema {...zodSchemaProps} />
60
+ </ts.VarDeclaration>
61
+ </ZodCustomTypeComponent>
62
+ </DeclaringTypeContext.Provider>
63
+ )
64
+ }
api/spec/packages/typespec-typescript/src/components/index.ts ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ export type { ZodCustomTypeComponentProps } from './ZodCustomTypeComponent.jsx'
2
+ export * from './ZodOptions.jsx'
3
+ export * from './ZodSchema.jsx'
4
+ export * from './ZodSchemaDeclaration.jsx'
api/spec/packages/typespec-typescript/src/context/zod-options.ts ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ type Children,
3
+ type ComponentContext,
4
+ type ComponentDefinition,
5
+ createContext,
6
+ useContext,
7
+ } from '@alloy-js/core'
8
+ import type {
9
+ ObjectPropertyProps,
10
+ VarDeclarationProps,
11
+ } from '@alloy-js/typescript'
12
+ import type {
13
+ Enum,
14
+ EnumMember,
15
+ Model,
16
+ ModelProperty,
17
+ Program,
18
+ Scalar,
19
+ Type,
20
+ Union,
21
+ UnionVariant,
22
+ } from '@typespec/compiler'
23
+ import { $ } from '@typespec/compiler/typekit'
24
+ import { isBuiltIn } from '../utils.jsx'
25
+
26
+ const getEmitOptionsForTypeSym: unique symbol = Symbol.for(
27
+ 'typespec-typescript:getEmitOptionsForType',
28
+ )
29
+
30
+ const getEmitOptionsForTypeKindSym: unique symbol = Symbol.for(
31
+ 'typespec-typescript:getEmitOptionsForTypeKind',
32
+ )
33
+
34
+ export type ZodCustomEmitOptions = ZodCustomEmitOptionsClass
35
+ export const ZodCustomEmitOptions = (() => new ZodCustomEmitOptionsClass()) as {
36
+ new (): ZodCustomEmitOptionsClass
37
+ (): ZodCustomEmitOptionsClass
38
+ }
39
+
40
+ export class ZodCustomEmitOptionsClass {
41
+ #typeEmitOptions: Map<Type, ZodCustomEmitOptionsBase<any>> = new Map()
42
+ #typeKindEmitOptions: Map<Type['kind'], ZodCustomEmitOptionsBase<any>> =
43
+ new Map()
44
+
45
+ forType<const T extends Type>(type: T, options: ZodCustomEmitOptionsBase<T>) {
46
+ this.#typeEmitOptions.set(type, options)
47
+
48
+ return this
49
+ }
50
+
51
+ forTypeKind<const TKind extends Type['kind']>(
52
+ typeKind: TKind,
53
+ options: ZodCustomEmitOptionsBase<Extract<Type, { kind: TKind }>>,
54
+ ) {
55
+ this.#typeKindEmitOptions.set(typeKind, options)
56
+
57
+ return this
58
+ }
59
+
60
+ /**
61
+ * @internal
62
+ */
63
+ [getEmitOptionsForTypeSym](program: Program, type: Type) {
64
+ const direct = this.#typeEmitOptions.get(type)
65
+ if (direct || !$(program).scalar.is(type) || isBuiltIn(program, type)) {
66
+ return direct
67
+ }
68
+
69
+ let currentScalar: Scalar | undefined = type
70
+ while (
71
+ currentScalar &&
72
+ !isBuiltIn(program, currentScalar) &&
73
+ !this.#typeEmitOptions.has(currentScalar)
74
+ ) {
75
+ currentScalar = currentScalar?.baseScalar
76
+ }
77
+
78
+ if (!currentScalar) {
79
+ return undefined
80
+ }
81
+
82
+ return this.#typeEmitOptions.get(currentScalar)
83
+ }
84
+
85
+ /**
86
+ * @internal
87
+ */
88
+ [getEmitOptionsForTypeKindSym](_program: Program, typeKind: Type['kind']) {
89
+ return this.#typeKindEmitOptions.get(typeKind)
90
+ }
91
+ }
92
+
93
+ export interface ZodCustomEmitPropsBase<TCustomType extends Type> {
94
+ type: TCustomType
95
+ default: Children
96
+ baseSchemaParts: () => Children
97
+ constraintParts: () => Children
98
+ descriptionParts: () => Children
99
+ }
100
+
101
+ export type CustomTypeToProps<TCustomType extends Type> =
102
+ TCustomType extends ModelProperty
103
+ ? ObjectPropertyProps
104
+ : TCustomType extends EnumMember
105
+ ? Record<string, never>
106
+ : TCustomType extends UnionVariant
107
+ ? Record<string, never>
108
+ : TCustomType extends Model | Scalar | Union | Enum
109
+ ? VarDeclarationProps
110
+ : VarDeclarationProps | ObjectPropertyProps
111
+
112
+ export interface ZodCustomEmitReferenceProps<
113
+ TCustomType extends Type,
114
+ > extends ZodCustomEmitPropsBase<TCustomType> {
115
+ member?: ModelProperty
116
+ memberParts: () => Children
117
+ }
118
+
119
+ export interface ZodCustomEmitDeclareProps<
120
+ TCustomType extends Type,
121
+ > extends ZodCustomEmitPropsBase<TCustomType> {
122
+ Declaration: ComponentDefinition<CustomTypeToProps<TCustomType>>
123
+ declarationProps: CustomTypeToProps<TCustomType>
124
+ }
125
+
126
+ export type ZodCustomDeclarationComponent<TCustomType extends Type> =
127
+ ComponentDefinition<ZodCustomEmitDeclareProps<TCustomType>>
128
+
129
+ export type ZodCustomReferenceComponent<TCustomType extends Type> =
130
+ ComponentDefinition<ZodCustomEmitReferenceProps<TCustomType>>
131
+
132
+ export interface ZodCustomEmitOptionsBase<TCustomType extends Type> {
133
+ declare?: ZodCustomDeclarationComponent<TCustomType>
134
+ reference?: ZodCustomReferenceComponent<TCustomType>
135
+ noDeclaration?: boolean
136
+ }
137
+
138
+ export interface ZodOptionsContext {
139
+ customEmit?: ZodCustomEmitOptions
140
+ }
141
+
142
+ export const ZodOptionsContext: ComponentContext<ZodOptionsContext> =
143
+ createContext({})
144
+
145
+ export function useZodOptions(): ZodOptionsContext {
146
+ return useContext(ZodOptionsContext)!
147
+ }
148
+
149
+ export function getEmitOptionsForType(
150
+ program: Program,
151
+ type: Type,
152
+ options?: ZodCustomEmitOptions,
153
+ ) {
154
+ return options?.[getEmitOptionsForTypeSym](program, type)
155
+ }
156
+
157
+ export function getEmitOptionsForTypeKind(
158
+ program: Program,
159
+ typeKind: Type['kind'],
160
+ options?: ZodCustomEmitOptions,
161
+ ) {
162
+ return options?.[getEmitOptionsForTypeKindSym](program, typeKind)
163
+ }
api/spec/packages/typespec-typescript/src/emitter.tsx ADDED
@@ -0,0 +1,523 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as ay from '@alloy-js/core'
2
+ import * as ts from '@alloy-js/typescript'
3
+ import {
4
+ type EmitContext,
5
+ getFriendlyName,
6
+ ListenerFlow,
7
+ type Model,
8
+ navigateProgram,
9
+ type Operation,
10
+ type Program,
11
+ type Type,
12
+ type Union,
13
+ } from '@typespec/compiler'
14
+ import { $ } from '@typespec/compiler/typekit'
15
+ // Registers the experimental HTTP typekit ($.httpOperation, $.modelProperty
16
+ // HTTP helpers) used by the operation walk and metadata stripping.
17
+ import '@typespec/http/experimental/typekit'
18
+ import { Output, writeOutput } from '@typespec/emitter-framework'
19
+ import { ZodSchemaDeclaration } from './components/ZodSchemaDeclaration.jsx'
20
+ import { zod } from './external-packages/zod.js'
21
+ import type { ZodEmitterOptions } from './lib.js'
22
+ import {
23
+ collectHttpOperations,
24
+ isInternalOperation,
25
+ operationSchemas,
26
+ } from './ZodOperations.jsx'
27
+ import { resolveStrippedNames } from './strip-prefixes.js'
28
+ import { newTopologicalTypeCollector, WireModeContext } from './utils.jsx'
29
+ import { RUNTIME_TEMPLATES } from './runtime-templates.js'
30
+ import { assertCasingDerivable } from './casing-gate.js'
31
+ import { WIRE_RUNTIME } from './wire-runtime.js'
32
+ import { interfacesFile } from './interface-types.js'
33
+ import { inputVariantName } from './input-variants.js'
34
+ import {
35
+ computeResponseReachableModels,
36
+ setResponseReachableModels,
37
+ } from './visibility.js'
38
+ import { findPaginationTemplates, paginationInfo } from './pagination.js'
39
+ import {
40
+ groupOperations,
41
+ jsonBodyOverrides,
42
+ sdkOperation,
43
+ type SdkOperation,
44
+ } from './sdk-operations.js'
45
+ import { requestTypesFor } from './request-types.js'
46
+ import { readmeFile, type ReadmeResource } from './readme.js'
47
+ import {
48
+ facadeFile,
49
+ funcsFile,
50
+ funcsIndexFile,
51
+ indexFile,
52
+ internalFile,
53
+ namespaceFile,
54
+ operationsAssertFile,
55
+ operationsFile,
56
+ sdkRootFile,
57
+ } from './sdk-files.js'
58
+
59
+ /**
60
+ * The base name a type is declared under before prefix stripping: its
61
+ * `@friendlyName` if present, otherwise its own name.
62
+ */
63
+ function baseName(program: Program, type: Type): string | undefined {
64
+ const friendly = getFriendlyName(program, type)
65
+ if (friendly) return friendly
66
+ return 'name' in type && typeof type.name === 'string' ? type.name : undefined
67
+ }
68
+
69
+ export async function $onEmit(context: EmitContext<ZodEmitterOptions>) {
70
+ const types = getAllDataTypes(context.program)
71
+ const tsNamePolicy = ts.createTSNamePolicy()
72
+ const stripPrefixes = context.options['strip-name-prefixes'] ?? []
73
+
74
+ const operations = collectHttpOperations(
75
+ context.program,
76
+ context.options['include-services'],
77
+ )
78
+
79
+ // Gate visibility filtering on response reachability before any model is
80
+ // walked: a create-/update-only property is dropped from a model's read shape
81
+ // only when that model appears in a response body. Set once, consulted by
82
+ // every property walker (interfaces, zod schemas, input-variant detection).
83
+ setResponseReachableModels(
84
+ context.program,
85
+ computeResponseReachableModels(context.program, operations),
86
+ )
87
+
88
+ const bodyOverrides = jsonBodyOverrides(context.program)
89
+ const opSchemas = operations.flatMap((op) =>
90
+ operationSchemas(context.program, op, bodyOverrides),
91
+ )
92
+
93
+ // Pre-pass: collision-guarded resolved names so a strip is only applied when
94
+ // it does not clash with another schema (mirrors the TS SDK emitter). The
95
+ // per-operation schema names join the pool so strips never collide with them
96
+ // either.
97
+ const baseNames = [
98
+ ...types
99
+ .map((type) => baseName(context.program, type))
100
+ .filter((n): n is string => n !== undefined),
101
+ ...opSchemas.map((s) => s.baseName),
102
+ ]
103
+ const resolved = resolveStrippedNames(baseNames, stripPrefixes)
104
+
105
+ const resolveName = (type: Type): string | undefined => {
106
+ const base = baseName(context.program, type)
107
+ return base ? (resolved.get(base) ?? base) : undefined
108
+ }
109
+ const models = types.filter((t): t is Model => t.kind === 'Model')
110
+ // A union can be declared in TypeSpec — and still picked up as a zod schema,
111
+ // since `getAllDataTypes` walks the whole namespace tree — without anything
112
+ // in the SDK surface ever referencing it (`PriceUsageBased` is reserved for
113
+ // future use; `ULIDOrResourceKey`/`ULIDOrExternalResourceKey` are unused
114
+ // today). Such a union has no meaningful shape for an SDK user to import
115
+ // (often literally `string | string`), so it is excluded from the `types.ts`
116
+ // alias pass even though its zod schema is still emitted.
117
+ const reachableUnions = computeReachableUnions(context.program, operations)
118
+ const unions = types.filter(
119
+ (t): t is Union => t.kind === 'Union' && reachableUnions.has(t),
120
+ )
121
+
122
+ // Fail the build if any wire key is not recoverable from its camelCase public
123
+ // form by the deterministic casing rule, before emitting anything that relies
124
+ // on it.
125
+ assertCasingDerivable(context.program, models, operations)
126
+
127
+ const interfaceName = (name: string) =>
128
+ tsNamePolicy.getName(name, 'interface')
129
+ const interfaces = interfacesFile(
130
+ context.program,
131
+ models,
132
+ unions,
133
+ resolveName,
134
+ (name) => tsNamePolicy.getName(name, 'variable'),
135
+ interfaceName,
136
+ )
137
+
138
+ // A type resolves to its documented interface only when its resolved name
139
+ // matches an emitted interface (string-based, so template instantiations whose
140
+ // Type identity differs from the collected model still resolve).
141
+ const emittedInterfaceNames = new Set(
142
+ [...models, ...unions]
143
+ .map((m) => resolveName(m))
144
+ .filter((n): n is string => Boolean(n))
145
+ .map(interfaceName),
146
+ )
147
+ const resolveInterface = (type: Type | undefined): string | undefined => {
148
+ if (!type) {
149
+ return undefined
150
+ }
151
+ const resolved = resolveName(type)
152
+ if (!resolved) {
153
+ return undefined
154
+ }
155
+ const name = interfaceName(resolved)
156
+ return emittedInterfaceNames.has(name) ? name : undefined
157
+ }
158
+
159
+ // String-keyed set of interface names whose input shape diverges, so a request
160
+ // body resolves to its `…Input` variant even when the HTTP body Type identity
161
+ // differs from the collected model (same bridging as `resolveInterface`).
162
+ const divergentInterfaceNames = new Set(
163
+ [...interfaces.divergentModels, ...interfaces.divergentUnions]
164
+ .map((m) => resolveName(m))
165
+ .filter((n): n is string => Boolean(n))
166
+ .map(interfaceName),
167
+ )
168
+ const resolveRequestBody = (type: Type | undefined): string | undefined => {
169
+ const name = resolveInterface(type)
170
+ if (!name) {
171
+ return undefined
172
+ }
173
+ return divergentInterfaceNames.has(name) ? inputVariantName(name) : name
174
+ }
175
+
176
+ const groups = groupOperations(context.program, operations)
177
+ const resources = [...groups.keys()]
178
+ const sdkFiles: Array<{ path: string; content: string }> = []
179
+ const readmeResources: ReadmeResource[] = []
180
+ // Groups' x-internal operations, destined for the `client.internal.*`
181
+ // surface. They share their group's funcs and operations modules with the
182
+ // public ops; only the facade classes are split. A group whose operations
183
+ // are all internal (e.g. currencies) gets no public facade at all.
184
+ const internalResources: ReadmeResource[] = []
185
+ const publicResources: string[] = []
186
+ const paginationTemplates = findPaginationTemplates(context.program)
187
+ // Names the operations modules export (`<Base>Request`/`<Base>Response`/
188
+ // `<Base>Query`), mirroring requestDecl/queryType/responseDecl in
189
+ // request-types.ts. indexFile must not re-export a same-named domain model:
190
+ // the explicit re-export would shadow the operation alias at the package
191
+ // root, and the two are not interchangeable (the alias wraps path params and
192
+ // AcceptDateStrings widening).
193
+ const operationTypeNames = new Set<string>()
194
+ for (const [resource, ops] of groups) {
195
+ const sdkOps = ops.map((op) =>
196
+ sdkOperation(
197
+ context.program,
198
+ op,
199
+ resource,
200
+ resolveInterface,
201
+ resolveRequestBody,
202
+ bodyOverrides,
203
+ ),
204
+ )
205
+ ops.forEach((op, i) => {
206
+ sdkOps[i]!.pagination = paginationInfo(
207
+ context.program,
208
+ op,
209
+ paginationTemplates,
210
+ resolveInterface,
211
+ )
212
+ })
213
+ for (const op of sdkOps) {
214
+ operationTypeNames.add(`${op.base}Request`)
215
+ operationTypeNames.add(`${op.base}Response`)
216
+ if (op.queryParams.length > 0) {
217
+ operationTypeNames.add(`${op.base}Query`)
218
+ }
219
+ }
220
+ const publicOps: SdkOperation[] = []
221
+ const internalOps: SdkOperation[] = []
222
+ ops.forEach((op, i) => {
223
+ const target = isInternalOperation(context.program, op)
224
+ ? internalOps
225
+ : publicOps
226
+ target.push(sdkOps[i]!)
227
+ })
228
+ const file = namespaceFile(resource)
229
+ const requestTypes = requestTypesFor(
230
+ context.program,
231
+ ops,
232
+ sdkOps,
233
+ interfaces.refNameInput,
234
+ bodyOverrides,
235
+ )
236
+ sdkFiles.push({
237
+ path: `src/models/operations/${file}.ts`,
238
+ content: operationsFile(resource, requestTypes),
239
+ })
240
+ const operationsAsserts = operationsAssertFile(resource, requestTypes)
241
+ if (operationsAsserts) {
242
+ sdkFiles.push({
243
+ path: `src/models/operations/${file}.assert.ts`,
244
+ content: operationsAsserts,
245
+ })
246
+ }
247
+ sdkFiles.push({
248
+ path: `src/funcs/${file}.ts`,
249
+ content: funcsFile(resource, sdkOps),
250
+ })
251
+ if (publicOps.length > 0) {
252
+ publicResources.push(resource)
253
+ readmeResources.push({ resource, ops: publicOps })
254
+ sdkFiles.push({
255
+ path: `src/sdk/${file}.ts`,
256
+ content: facadeFile(resource, publicOps),
257
+ })
258
+ }
259
+ if (internalOps.length > 0) {
260
+ internalResources.push({ resource, ops: internalOps })
261
+ }
262
+ }
263
+ if (internalResources.length > 0) {
264
+ sdkFiles.push({
265
+ path: 'src/sdk/internal.ts',
266
+ content: internalFile(internalResources),
267
+ })
268
+ }
269
+ sdkFiles.push({ path: 'src/lib/wire.ts', content: WIRE_RUNTIME })
270
+ sdkFiles.push({ path: 'src/models/types.ts', content: interfaces.types })
271
+ sdkFiles.push({
272
+ path: 'src/models/types.assert.ts',
273
+ content: interfaces.asserts,
274
+ })
275
+ sdkFiles.push({
276
+ path: 'src/funcs/index.ts',
277
+ content: funcsIndexFile(resources),
278
+ })
279
+ sdkFiles.push({
280
+ path: 'src/sdk/sdk.ts',
281
+ content: sdkRootFile(publicResources, internalResources.length > 0),
282
+ })
283
+ sdkFiles.push({
284
+ path: 'src/index.ts',
285
+ content: indexFile(
286
+ publicResources,
287
+ resources,
288
+ interfaces.typeNames,
289
+ operationTypeNames,
290
+ ),
291
+ })
292
+ sdkFiles.push({
293
+ path: 'README.md',
294
+ content: readmeFile(
295
+ readmeResources,
296
+ context.options['package-name'],
297
+ context.options['readme-note'],
298
+ internalResources,
299
+ ),
300
+ })
301
+
302
+ // Stamped on every emitted file (repo convention for generated code): the
303
+ // four regenerated tests/ files and README.md are indistinguishable from
304
+ // hand-written files without it, inviting edits the next generate reverts.
305
+ const generatedComment =
306
+ 'Code generated by @openmeter/typespec-typescript. DO NOT EDIT.'
307
+
308
+ // writeOutput is async; without the await, $onEmit resolves before the
309
+ // files land on the host — the tsp CLI masks this (the process outlives the
310
+ // pending writes), but in-memory compilation (the emitter test harness)
311
+ // observes a partially written output directory.
312
+ await writeOutput(
313
+ context.program,
314
+ <Output
315
+ program={context.program}
316
+ namePolicy={tsNamePolicy}
317
+ externals={[zod]}
318
+ >
319
+ <ts.SourceFile
320
+ path="src/models/schemas.ts"
321
+ headerComment={generatedComment}
322
+ >
323
+ <ay.For
324
+ each={types}
325
+ ender={';'}
326
+ joiner={
327
+ <>
328
+ ;<hbr />
329
+ <hbr />
330
+ </>
331
+ }
332
+ >
333
+ {(type) => {
334
+ const base = baseName(context.program, type)
335
+ const name = base ? resolved.get(base) : undefined
336
+ return <ZodSchemaDeclaration type={type} name={name} export />
337
+ }}
338
+ </ay.For>
339
+ ;<hbr />
340
+ <hbr />
341
+ <ay.For
342
+ each={opSchemas}
343
+ ender={';'}
344
+ joiner={
345
+ <>
346
+ ;<hbr />
347
+ <hbr />
348
+ </>
349
+ }
350
+ >
351
+ {(schema) =>
352
+ schema.render(resolved.get(schema.baseName) ?? schema.baseName)
353
+ }
354
+ </ay.For>
355
+ ;<hbr />
356
+ <hbr />
357
+ {/* The snake_case wire pass: the same models and per-op body/response
358
+ schemas re-emitted strict for the optional `validate` option. Because
359
+ both come from one walk over the same types, they are structurally
360
+ identical except for casing and strictness. */}
361
+ <WireModeContext.Provider value={true}>
362
+ <ay.For
363
+ each={types}
364
+ ender={';'}
365
+ joiner={
366
+ <>
367
+ ;<hbr />
368
+ <hbr />
369
+ </>
370
+ }
371
+ >
372
+ {(type) => {
373
+ const base = baseName(context.program, type)
374
+ const name = base ? resolved.get(base) : undefined
375
+ return (
376
+ <ZodSchemaDeclaration
377
+ type={type}
378
+ name={name ? `${name}Wire` : undefined}
379
+ export
380
+ />
381
+ )
382
+ }}
383
+ </ay.For>
384
+ ;<hbr />
385
+ <hbr />
386
+ <ay.For
387
+ each={opSchemas}
388
+ ender={';'}
389
+ joiner={
390
+ <>
391
+ ;<hbr />
392
+ <hbr />
393
+ </>
394
+ }
395
+ >
396
+ {(schema) =>
397
+ schema.render(
398
+ `${resolved.get(schema.baseName) ?? schema.baseName}Wire`,
399
+ )
400
+ }
401
+ </ay.For>
402
+ </WireModeContext.Provider>
403
+ </ts.SourceFile>
404
+ {Object.entries(RUNTIME_TEMPLATES).map(([path, content]) => (
405
+ <ts.SourceFile path={path} headerComment={generatedComment}>
406
+ {content}
407
+ </ts.SourceFile>
408
+ ))}
409
+ {sdkFiles.map(({ path, content }) =>
410
+ path.endsWith('.md') ? (
411
+ <ts.SourceFile path={path}>
412
+ {`<!-- ${generatedComment} -->\n\n${content}`}
413
+ </ts.SourceFile>
414
+ ) : (
415
+ <ts.SourceFile path={path} headerComment={generatedComment}>
416
+ {content}
417
+ </ts.SourceFile>
418
+ ),
419
+ )}
420
+ </Output>,
421
+ context.emitterOutputDir,
422
+ )
423
+ }
424
+
425
+ /**
426
+ * Collects all the models defined in the spec and returns them in
427
+ * topologically sorted order. Types are ordered such that dependencies appear
428
+ * before the types that depend on them.
429
+ */
430
+ function getAllDataTypes(program: Program) {
431
+ const collector = newTopologicalTypeCollector(program)
432
+ const globalNs = program.getGlobalNamespaceType()
433
+
434
+ navigateProgram(
435
+ program,
436
+ {
437
+ namespace(n) {
438
+ if (n !== globalNs && !$(program).type.isUserDefined(n)) {
439
+ return ListenerFlow.NoRecursion
440
+ }
441
+ return undefined
442
+ },
443
+ model: collector.collectType,
444
+ enum: collector.collectType,
445
+ union: collector.collectType,
446
+ scalar: collector.collectType,
447
+ },
448
+ { includeTemplateDeclaration: false },
449
+ )
450
+
451
+ return collector.types
452
+ }
453
+
454
+ /**
455
+ * Named unions transitively reachable from any operation's request body, query
456
+ * parameters, or response body (success or error) — descending through model
457
+ * properties, indexers, base/derived models, and nested union variants, the
458
+ * same shape the interface/schema walkers traverse. Used to gate which unions
459
+ * earn a `types.ts` alias: a declared-but-unreferenced union is still
460
+ * collected as a zod schema (see `getAllDataTypes`), but has nothing to alias
461
+ * to that an SDK caller could actually encounter.
462
+ */
463
+ function computeReachableUnions(
464
+ program: Program,
465
+ operations: Operation[],
466
+ ): Set<Union> {
467
+ const reachable = new Set<Union>()
468
+ const visited = new Set<Type>()
469
+
470
+ const visit = (type: Type | undefined): void => {
471
+ if (!type || visited.has(type)) {
472
+ return
473
+ }
474
+ visited.add(type)
475
+ switch (type.kind) {
476
+ case 'Model':
477
+ if (type.indexer) {
478
+ visit(type.indexer.value)
479
+ }
480
+ if (type.baseModel) {
481
+ visit(type.baseModel)
482
+ }
483
+ for (const derived of type.derivedModels) {
484
+ visit(derived)
485
+ }
486
+ for (const prop of type.properties.values()) {
487
+ visit(prop.type)
488
+ }
489
+ break
490
+ case 'Union':
491
+ reachable.add(type)
492
+ for (const variant of type.variants.values()) {
493
+ visit(variant.type)
494
+ }
495
+ break
496
+ case 'Tuple':
497
+ for (const value of type.values) {
498
+ visit(value)
499
+ }
500
+ break
501
+ default:
502
+ break
503
+ }
504
+ }
505
+
506
+ const tk = $(program)
507
+ for (const op of operations) {
508
+ const httpOp = tk.httpOperation.get(op)
509
+ visit(httpOp.parameters.body?.type)
510
+ for (const param of httpOp.parameters.parameters) {
511
+ if (param.type === 'query') {
512
+ visit(param.param.type)
513
+ }
514
+ }
515
+ for (const response of httpOp.responses) {
516
+ for (const content of response.responses) {
517
+ visit(content.body?.type)
518
+ }
519
+ }
520
+ }
521
+
522
+ return reachable
523
+ }
api/spec/packages/typespec-typescript/src/external-packages/zod.ts ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createPackage } from '@alloy-js/typescript'
2
+ import packageJson from 'zod/package.json' with { type: 'json' }
3
+
4
+ export const zod = createPackage({
5
+ name: 'zod',
6
+ version: packageJson.version,
7
+ descriptor: {
8
+ '.': {
9
+ named: ['z'],
10
+ },
11
+ },
12
+ })
api/spec/packages/typespec-typescript/src/http-status.ts ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { HttpStatusCodesEntry } from '@typespec/http'
2
+
3
+ /**
4
+ * Whether a response's status code(s) mark it as a success response — the ONE
5
+ * definition every walk must share (operation schemas, request/response types,
6
+ * visibility, casing gate). A divergent local copy is how an operation's
7
+ * compile-time types and its runtime mapping drift apart: a body one walk sees
8
+ * and another skips ships with unverified casing or a mismatched schema.
9
+ *
10
+ * Semantics: a single status is success in 200–299; a `{start, end}` range is
11
+ * success when it overlaps 200–299; the `"*"` default-response wildcard IS
12
+ * success — its body is mapped at runtime when no numbered success response
13
+ * exists, so every compile-time walk (including the casing gate) must examine
14
+ * it too.
15
+ */
16
+ export function isSuccessStatus(statusCodes: HttpStatusCodesEntry): boolean {
17
+ if (statusCodes === '*') {
18
+ return true
19
+ }
20
+ if (typeof statusCodes === 'number') {
21
+ return statusCodes >= 200 && statusCodes < 300
22
+ }
23
+ return statusCodes.start < 300 && statusCodes.end >= 200
24
+ }
api/spec/packages/typespec-typescript/src/index.ts ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ export * from './components/index.js'
2
+ export * from './context/zod-options.js'
3
+ export { $onEmit } from './emitter.jsx'
4
+ export * from './external-packages/zod.js'
5
+ export { $lib } from './lib.js'
6
+ export * from './utils.js'
api/spec/packages/typespec-typescript/src/input-variants.ts ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ type Model,
3
+ type Program,
4
+ type Type,
5
+ type Union,
6
+ } from '@typespec/compiler'
7
+ import { bodyProperties } from './utils.jsx'
8
+
9
+ /**
10
+ * The input shape of a model differs from its output shape only when a defaulted
11
+ * property — anywhere in its reachable subtree — flips from required (output) to
12
+ * optional (input). This computes the set of models for which an `…Input`
13
+ * variant interface must be emitted.
14
+ *
15
+ * A model diverges-on-input iff it has a defaulted property, or any property
16
+ * type (descending through arrays, records, unions, tuples, and `baseModel`,
17
+ * matching the schema walker) transitively reaches a model that diverges.
18
+ */
19
+ export function computeDivergentModels(
20
+ program: Program,
21
+ models: Model[],
22
+ ): Set<Model> {
23
+ const diverges = new Set<Model>()
24
+ const memo = new Map<Model, boolean>()
25
+ const stack = new Set<Model>()
26
+
27
+ const reaches = (type: Type): boolean => {
28
+ switch (type.kind) {
29
+ case 'Model':
30
+ return modelDiverges(type)
31
+ case 'ModelProperty':
32
+ return reaches(type.type)
33
+ case 'Union':
34
+ for (const variant of type.variants.values()) {
35
+ if (reaches(variant.type)) {
36
+ return true
37
+ }
38
+ }
39
+ return false
40
+ case 'Tuple':
41
+ return type.values.some(reaches)
42
+ default:
43
+ return false
44
+ }
45
+ }
46
+
47
+ const modelDiverges = (model: Model): boolean => {
48
+ const cached = memo.get(model)
49
+ if (cached !== undefined) {
50
+ return cached
51
+ }
52
+ // A cycle that reaches back to an in-progress model contributes nothing on
53
+ // its own; the originating frame still sees its other branches.
54
+ if (stack.has(model)) {
55
+ return false
56
+ }
57
+ stack.add(model)
58
+
59
+ let result = false
60
+ if (model.indexer && reaches(model.indexer.value)) {
61
+ result = true
62
+ }
63
+ if (!result && model.baseModel && modelDiverges(model.baseModel)) {
64
+ result = true
65
+ }
66
+ if (!result) {
67
+ for (const prop of bodyProperties(program, model)) {
68
+ if (prop.defaultValue !== undefined || reaches(prop.type)) {
69
+ result = true
70
+ break
71
+ }
72
+ }
73
+ }
74
+
75
+ stack.delete(model)
76
+ memo.set(model, result)
77
+ if (result) {
78
+ diverges.add(model)
79
+ }
80
+ return result
81
+ }
82
+
83
+ for (const model of models) {
84
+ modelDiverges(model)
85
+ }
86
+ return diverges
87
+ }
88
+
89
+ /** The interface name of a model's input variant. */
90
+ export function inputVariantName(outputName: string): string {
91
+ return `${outputName}Input`
92
+ }
93
+
94
+ /**
95
+ * Named unions that need an `…Input` alias: at least one variant is a model
96
+ * whose own input shape diverges (see {@link computeDivergentModels}). Unions
97
+ * carry no properties of their own, so — unlike a model — a union never
98
+ * diverges on its own account; it only needs an `…Input` variant when
99
+ * substituting one of its variants for that variant's `…Input` interface
100
+ * actually changes the union's shape. Only the immediate variants are
101
+ * checked (matching the requirement that drives it: picking the right
102
+ * variant interface per branch, not a transitive default-value walk).
103
+ */
104
+ export function computeDivergentUnions(
105
+ unions: Union[],
106
+ divergentModels: Set<Model>,
107
+ ): Set<Union> {
108
+ const diverges = new Set<Union>()
109
+ for (const union of unions) {
110
+ for (const variant of union.variants.values()) {
111
+ if (variant.type.kind === 'Model' && divergentModels.has(variant.type)) {
112
+ diverges.add(union)
113
+ break
114
+ }
115
+ }
116
+ }
117
+ return diverges
118
+ }
api/spec/packages/typespec-typescript/src/interface-types.ts ADDED
@@ -0,0 +1,280 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ type Model,
3
+ type Program,
4
+ type Type,
5
+ type Union,
6
+ } from '@typespec/compiler'
7
+ import { $ } from '@typespec/compiler/typekit'
8
+ import { bodyProperties, jsdoc, publicPropertyName } from './utils.jsx'
9
+ import {
10
+ type IoMode,
11
+ type RefName,
12
+ isOptional,
13
+ tsTypeOf,
14
+ unionVariantsType,
15
+ } from './ts-types.js'
16
+ import {
17
+ computeDivergentModels,
18
+ computeDivergentUnions,
19
+ inputVariantName,
20
+ } from './input-variants.js'
21
+
22
+ type ResolveName = (type: Type) => string | undefined
23
+ type SchemaName = (resolvedName: string) => string
24
+
25
+ /**
26
+ * A mutual-assignability check between an emitted type and its wire schema. The
27
+ * emitted type is walked from TypeSpec independently of zod, so this is the only
28
+ * place the two artifacts meet: any divergence becomes a build error.
29
+ */
30
+ function conformanceGuard(name: string, schemaRef: string): string {
31
+ return (
32
+ `type _Assert${name} = [${name}] extends [${schemaRef}]\n` +
33
+ ` ? [${schemaRef}] extends [${name}]\n` +
34
+ ` ? true\n` +
35
+ ` : { __error: '${name} is missing fields present in the wire schema' }\n` +
36
+ ` : { __error: '${name} has fields not present in the wire schema' }\n` +
37
+ `const _assert${name}: _Assert${name} = true`
38
+ )
39
+ }
40
+
41
+ /**
42
+ * A one-directional check for input variants: the emitted type must be a valid
43
+ * input the schema accepts (`[X] extends [z.input]`), but not vice versa. zod's
44
+ * `z.input` of a coerced leaf (e.g. `z.coerce.bigint()`) is the loose `unknown`;
45
+ * the emitted type deliberately keeps the strict leaf (`bigint`), so the reverse
46
+ * direction is intentionally dropped instead of widening the public type.
47
+ */
48
+ function inputConformanceGuard(name: string, schemaRef: string): string {
49
+ return (
50
+ `type _Assert${name} = [${name}] extends [${schemaRef}]\n` +
51
+ ` ? true\n` +
52
+ ` : { __error: '${name} is not assignable to the wire input schema' }\n` +
53
+ `const _assert${name}: _Assert${name} = true`
54
+ )
55
+ }
56
+
57
+ function interfaceBody(
58
+ program: Program,
59
+ model: Model,
60
+ refName: RefName,
61
+ io: IoMode,
62
+ ): string {
63
+ const tk = $(program)
64
+ const lines: string[] = []
65
+ for (const prop of bodyProperties(program, model)) {
66
+ const doc = jsdoc(tk.type.getDoc(prop), ' ')
67
+ if (doc) {
68
+ lines.push(doc)
69
+ }
70
+ const opt = isOptional(prop, io) ? '?' : ''
71
+ lines.push(
72
+ ` ${publicPropertyName(program, prop)}${opt}: ${tsTypeOf(program, prop.type, refName, io)}`,
73
+ )
74
+ }
75
+ // An indexer (`...Record<...>`) makes the model open; mirror it with an index
76
+ // signature so the interface stays assignable to the open wire shape while
77
+ // keeping its documented known fields.
78
+ if (model.indexer?.key.name === 'string') {
79
+ lines.push(
80
+ ` [key: string]: ${tsTypeOf(program, model.indexer.value, refName, io)}`,
81
+ )
82
+ }
83
+ return lines.join('\n')
84
+ }
85
+
86
+ export interface InterfacesResult {
87
+ types: string
88
+ asserts: string
89
+ /** Names of every interface/type exported from `types.ts`, including `…Input` variants. */
90
+ typeNames: string[]
91
+ /** Models whose input shape diverges from their output interface. */
92
+ divergentModels: Set<Model>
93
+ /** Named unions whose input shape diverges from their output alias. */
94
+ divergentUnions: Set<Union>
95
+ /**
96
+ * Input-mode ref resolver: the `…Input` variant for a divergent model or
97
+ * union, else the type's plain alias. Used to build request/query types.
98
+ */
99
+ refNameInput: RefName
100
+ }
101
+
102
+ export function interfacesFile(
103
+ program: Program,
104
+ models: Model[],
105
+ unions: Union[],
106
+ resolveName: ResolveName,
107
+ schemaName: SchemaName,
108
+ interfaceName: (resolvedName: string) => string,
109
+ ): InterfacesResult {
110
+ const tk = $(program)
111
+ const emitted = new Set<Type>([...models, ...unions])
112
+ const refName = (type: Type): string | undefined => {
113
+ if (!emitted.has(type)) {
114
+ return undefined
115
+ }
116
+ const resolved = resolveName(type)
117
+ return resolved ? interfaceName(resolved) : undefined
118
+ }
119
+
120
+ const divergent = computeDivergentModels(program, models)
121
+ const divergentUnions = computeDivergentUnions(unions, divergent)
122
+ // An input-mode ref points at a child's `…Input` variant when that child also
123
+ // diverges, so relaxed optionality propagates through the subtree.
124
+ const refNameInput: RefName = (type) => {
125
+ const name = refName(type)
126
+ if (!name) {
127
+ return undefined
128
+ }
129
+ if (type.kind === 'Model' && divergent.has(type)) {
130
+ return inputVariantName(name)
131
+ }
132
+ if (type.kind === 'Union' && divergentUnions.has(type)) {
133
+ return inputVariantName(name)
134
+ }
135
+ return name
136
+ }
137
+
138
+ const blocks: string[] = []
139
+ const inputBlocks: string[] = []
140
+ const asserts: string[] = []
141
+ for (const model of models) {
142
+ const resolved = resolveName(model)
143
+ if (!resolved) {
144
+ continue
145
+ }
146
+ const name = interfaceName(resolved)
147
+ const schemaRef = `z.output<typeof schemas.${schemaName(resolved)}>`
148
+ const doc = jsdoc(tk.type.getDoc(model), '')
149
+
150
+ // When the model `extends` an emitted base, the interface extends it too so
151
+ // inherited fields (and their docs) propagate; only own properties go in the
152
+ // body.
153
+ const baseName = model.baseModel ? refName(model.baseModel) : undefined
154
+ const extendsClause = baseName ? ` extends ${baseName}` : ''
155
+
156
+ const hasWireProps = bodyProperties(program, model).length > 0
157
+
158
+ // Models with no wire-mapped properties and no base (records, unions, marker
159
+ // types) have no structural interface — alias straight to the mapped type so
160
+ // it is correct (e.g. `Labels` -> `Record<string, string>`) rather than an
161
+ // empty, permissive `interface {}`.
162
+ if (!hasWireProps && !baseName) {
163
+ const parts: string[] = []
164
+ if (doc) {
165
+ parts.push(doc)
166
+ }
167
+ // Exclude the model from its own ref resolution so the alias expresses its
168
+ // structure (`Record<string, string>`) instead of aliasing to itself.
169
+ const structural: RefName = (type) =>
170
+ type === model ? undefined : refName(type)
171
+ parts.push(
172
+ `export type ${name} = ${tsTypeOf(program, model, structural)}`,
173
+ )
174
+ blocks.push(parts.join('\n'))
175
+ // The alias is an independently-walked type, not the inferred shape, so it
176
+ // is guarded too — unlike a `z.output` alias, it can diverge.
177
+ asserts.push(conformanceGuard(name, schemaRef))
178
+ continue
179
+ }
180
+
181
+ const body = interfaceBody(program, model, refName, 'output')
182
+ const parts: string[] = []
183
+ if (doc) {
184
+ parts.push(doc)
185
+ }
186
+ parts.push(`export interface ${name}${extendsClause} {\n${body}\n}`)
187
+ blocks.push(parts.join('\n'))
188
+ asserts.push(conformanceGuard(name, schemaRef))
189
+
190
+ // The input variant relaxes defaulted fields to optional (transitively); it
191
+ // is emitted only where the input shape actually diverges from the output.
192
+ if (divergent.has(model)) {
193
+ const inputNameStr = inputVariantName(name)
194
+ const inputExtends =
195
+ model.baseModel && divergent.has(model.baseModel)
196
+ ? ` extends ${inputVariantName(refName(model.baseModel)!)}`
197
+ : extendsClause
198
+ const inputBody = interfaceBody(program, model, refNameInput, 'input')
199
+ const inputParts: string[] = []
200
+ if (doc) {
201
+ inputParts.push(doc)
202
+ }
203
+ inputParts.push(
204
+ `export interface ${inputNameStr}${inputExtends} {\n${inputBody}\n}`,
205
+ )
206
+ inputBlocks.push(inputParts.join('\n'))
207
+ asserts.push(
208
+ inputConformanceGuard(
209
+ inputNameStr,
210
+ `z.input<typeof schemas.${schemaName(resolved)}>`,
211
+ ),
212
+ )
213
+ }
214
+ }
215
+
216
+ for (const union of unions) {
217
+ const resolved = resolveName(union)
218
+ if (!resolved) {
219
+ continue
220
+ }
221
+ const name = interfaceName(resolved)
222
+ const schemaRef = `z.output<typeof schemas.${schemaName(resolved)}>`
223
+ const doc = jsdoc(tk.type.getDoc(union), '')
224
+
225
+ // Exclude the union from its own ref resolution so the alias expands its
226
+ // variants (`PriceFree | PriceFlat | …`) instead of aliasing to itself.
227
+ const structural: RefName = (type) =>
228
+ type === union ? undefined : refName(type)
229
+ const parts: string[] = []
230
+ if (doc) {
231
+ parts.push(doc)
232
+ }
233
+ parts.push(
234
+ `export type ${name} = ${unionVariantsType(program, union, structural, 'output')}`,
235
+ )
236
+ blocks.push(parts.join('\n'))
237
+ asserts.push(conformanceGuard(name, schemaRef))
238
+
239
+ // Mirrors the model input variant above: emitted only when substituting a
240
+ // variant's own `…Input` interface actually changes the union's shape.
241
+ if (divergentUnions.has(union)) {
242
+ const inputNameStr = inputVariantName(name)
243
+ const structuralInput: RefName = (type) =>
244
+ type === union ? undefined : refNameInput(type)
245
+ const inputParts: string[] = []
246
+ if (doc) {
247
+ inputParts.push(doc)
248
+ }
249
+ inputParts.push(
250
+ `export type ${inputNameStr} = ${unionVariantsType(program, union, structuralInput, 'input')}`,
251
+ )
252
+ inputBlocks.push(inputParts.join('\n'))
253
+ asserts.push(
254
+ inputConformanceGuard(
255
+ inputNameStr,
256
+ `z.input<typeof schemas.${schemaName(resolved)}>`,
257
+ ),
258
+ )
259
+ }
260
+ }
261
+
262
+ const allBlocks = [...blocks, ...inputBlocks]
263
+ const typeNames = allBlocks
264
+ .map((b) => b.match(/export (?:interface|type) (\w+)/)?.[1])
265
+ .filter((n): n is string => Boolean(n))
266
+ const types = `${allBlocks.join('\n\n')}\n`
267
+ const assertImports =
268
+ `import { z } from 'zod'\n` +
269
+ `import * as schemas from './schemas.js'\n` +
270
+ `import type { ${typeNames.join(', ')} } from './types.js'\n`
271
+ const assertsFile = `${assertImports}\n${asserts.join('\n\n')}\n`
272
+ return {
273
+ types,
274
+ asserts: assertsFile,
275
+ typeNames,
276
+ divergentModels: divergent,
277
+ divergentUnions,
278
+ refNameInput,
279
+ }
280
+ }
api/spec/packages/typespec-typescript/src/lib.ts ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ createTypeSpecLibrary,
3
+ JSONSchemaType,
4
+ paramMessage,
5
+ } from '@typespec/compiler'
6
+
7
+ export interface ZodEmitterOptions {
8
+ 'package-name': string
9
+ 'readme-note'?: string
10
+ 'strip-name-prefixes'?: string[]
11
+ 'include-services'?: string[]
12
+ }
13
+
14
+ const EmitterOptionsSchema: JSONSchemaType<ZodEmitterOptions> = {
15
+ type: 'object',
16
+ additionalProperties: true,
17
+ properties: {
18
+ 'package-name': {
19
+ type: 'string',
20
+ description:
21
+ 'The npm package name the generated README installs and imports.',
22
+ },
23
+ 'readme-note': {
24
+ type: 'string',
25
+ nullable: true,
26
+ description:
27
+ 'Markdown inserted after the README intro, e.g. a GitHub alert callout.',
28
+ },
29
+ 'strip-name-prefixes': {
30
+ type: 'array',
31
+ items: { type: 'string' },
32
+ nullable: true,
33
+ description: 'Prefixes to strip from generated Zod schema',
34
+ },
35
+ 'include-services': {
36
+ type: 'array',
37
+ items: { type: 'string' },
38
+ nullable: true,
39
+ description:
40
+ 'Service namespace names whose operations get per-operation schemas. When omitted, all services are included.',
41
+ },
42
+ },
43
+ required: ['package-name'],
44
+ }
45
+
46
+ export const $lib = createTypeSpecLibrary({
47
+ name: 'typespec-typescript',
48
+ emitter: {
49
+ options: EmitterOptionsSchema,
50
+ },
51
+ // Every silent-degradation path in the emitter must report one of these.
52
+ // Nothing in the current spec triggers them — they exist so future spec
53
+ // shapes the emitter cannot faithfully map surface in the generate output
54
+ // instead of quietly eroding the SDK (a z.any() schema, or an endpoint
55
+ // missing from the published client).
56
+ diagnostics: {
57
+ 'unsupported-type': {
58
+ severity: 'warning',
59
+ messages: {
60
+ default: paramMessage`${'kind'} has no schema mapping and degrades to any/unknown in the generated SDK`,
61
+ },
62
+ },
63
+ 'ungrouped-operation': {
64
+ severity: 'warning',
65
+ messages: {
66
+ default: paramMessage`operation '${'operation'}' has no resolvable source interface (not authored via the extends/op-is pattern) and was omitted from the generated SDK`,
67
+ },
68
+ },
69
+ },
70
+ })
71
+
72
+ export const { reportDiagnostic, createDiagnostic } = $lib