Upload folder using huggingface_hub (part 3)
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- api/spec/packages/legacy/src/rest.tsp +43 -0
- api/spec/packages/legacy/src/subjects.tsp +156 -0
- api/spec/packages/legacy/src/types.tsp +447 -0
- api/spec/packages/legacy/tspconfig.client.yaml +21 -0
- api/spec/packages/legacy/tspconfig.yaml +14 -0
- api/spec/packages/typespec-go/.gitignore +2 -0
- api/spec/packages/typespec-go/README.md +76 -0
- api/spec/packages/typespec-go/package.json +34 -0
- api/spec/packages/typespec-go/src/components/GoClient.tsx +145 -0
- api/spec/packages/typespec-go/src/components/GoEnum.tsx +69 -0
- api/spec/packages/typespec-go/src/components/GoModels.tsx +206 -0
- api/spec/packages/typespec-go/src/components/GoResource.tsx +1000 -0
- api/spec/packages/typespec-go/src/components/GoStruct.tsx +41 -0
- api/spec/packages/typespec-go/src/components/GoTypeSpecEnum.tsx +55 -0
- api/spec/packages/typespec-go/src/components/GoUnion.tsx +389 -0
- api/spec/packages/typespec-go/src/emitter.tsx +602 -0
- api/spec/packages/typespec-go/src/go-types.tsx +731 -0
- api/spec/packages/typespec-go/src/grouping.ts +167 -0
- api/spec/packages/typespec-go/src/index.ts +2 -0
- api/spec/packages/typespec-go/src/lib.ts +102 -0
- api/spec/packages/typespec-go/src/operations.ts +470 -0
- api/spec/packages/typespec-go/src/projections.ts +1039 -0
- api/spec/packages/typespec-go/src/readme.ts +371 -0
- api/spec/packages/typespec-go/src/runtime-symbols.ts +81 -0
- api/spec/packages/typespec-go/src/runtime-templates.ts +868 -0
- api/spec/packages/typespec-go/src/stdlib.ts +89 -0
- api/spec/packages/typespec-go/test/assembly.test.ts +385 -0
- api/spec/packages/typespec-go/test/go-types.test.ts +100 -0
- api/spec/packages/typespec-go/test/operations.test.ts +586 -0
- api/spec/packages/typespec-go/test/projections.test.ts +504 -0
- api/spec/packages/typespec-go/test/resource-render.test.ts +324 -0
- api/spec/packages/typespec-go/tsconfig.json +34 -0
- api/spec/packages/typespec-typescript/.gitignore +2 -0
- api/spec/packages/typespec-typescript/package.json +38 -0
- api/spec/packages/typespec-typescript/src/ZodOperations.tsx +313 -0
- api/spec/packages/typespec-typescript/src/casing-gate.ts +250 -0
- api/spec/packages/typespec-typescript/src/casing.ts +18 -0
- api/spec/packages/typespec-typescript/src/components/ZodCustomTypeComponent.tsx +90 -0
- api/spec/packages/typespec-typescript/src/components/ZodOptions.tsx +24 -0
- api/spec/packages/typespec-typescript/src/components/ZodSchema.tsx +62 -0
- api/spec/packages/typespec-typescript/src/components/ZodSchemaDeclaration.tsx +64 -0
- api/spec/packages/typespec-typescript/src/components/index.ts +4 -0
- api/spec/packages/typespec-typescript/src/context/zod-options.ts +163 -0
- api/spec/packages/typespec-typescript/src/emitter.tsx +523 -0
- api/spec/packages/typespec-typescript/src/external-packages/zod.ts +12 -0
- api/spec/packages/typespec-typescript/src/http-status.ts +24 -0
- api/spec/packages/typespec-typescript/src/index.ts +6 -0
- api/spec/packages/typespec-typescript/src/input-variants.ts +118 -0
- api/spec/packages/typespec-typescript/src/interface-types.ts +280 -0
- 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
|