Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,16 @@ When `nubx` is available, OASmith runs its pinned Oxfmt version through
`nubx`'s local discovery and registry fallback. No Node project or installed
Oxfmt dependency is required. Generation still works without `nubx`.

Generated clients use the first document-level server by default and the first
operation-level server for that operation. An explicit client base URL always
overrides either declaration.

TypeScript clients emit JSON bodies, raw bodies as `BodyInit`, and fixed-length
ordered multipart bodies declared with `prefixItems` and `prefixEncoding`.
Binary multipart parts are `Blob` values; their media types must match the
content types declared by the corresponding prefix encoding. Unsupported
request-body shapes fail generation.

## OpenTelemetry trace propagation

Generated clients leave OpenTelemetry dependencies and SDK setup to the
Expand Down
53 changes: 49 additions & 4 deletions internal/tsemit/templates/api.ts.gotmpl
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,31 @@ export class ResponseError extends Error {
}
}

type ResponseTimeout = {
{{if .HasMultipartBody}}function multipartContentTypeAllowed(value: string, allowedValues: string): boolean {
const [mediaType] = value.trim().toLowerCase().split(';', 1);
const [major, minor, extra] = mediaType.split('/');
if (major === '' || minor === '' || extra !== undefined) {
return false;
}
for (const allowedValue of allowedValues.split(',')) {
const [allowedType] = allowedValue.trim().toLowerCase().split(';', 1);
const [allowedMajor, allowedMinor, allowedExtra] = allowedType.split('/');
if (
allowedExtra === undefined &&
major === allowedMajor &&
(minor === allowedMinor || allowedMinor === '*')
) {
return true;
}
}
return false;
}

function newMultipartBoundary(): string {
return 'oasmith-' + crypto.randomUUID();
}

{{end}}type ResponseTimeout = {
signal: AbortSignal;
stop(): void;
};
Expand Down Expand Up @@ -513,6 +537,7 @@ async function runInterceptors(

{{end}}export class DefaultApi {
private baseURL: string;
private baseURLOverride: boolean;
private fetch: typeof globalThis.fetch;
private interceptors: FetchInterceptor[];
private responseTimeoutMs: number | undefined;
Expand All @@ -524,7 +549,8 @@ async function runInterceptors(
{{end}}

constructor(options: ClientOptions = {}) {
this.baseURL = options.baseURL ?? '';
this.baseURLOverride = options.baseURL !== undefined;
this.baseURL = options.baseURL ?? {{if .DefaultServerURL}}{{printf "%q" .DefaultServerURL}}{{else}}''{{end}};
this.fetch = options.fetch ?? globalThis.fetch;
this.interceptors = options.interceptors ?? [];
this.responseTimeoutMs = configuredTimeout(
Expand Down Expand Up @@ -631,9 +657,25 @@ async function runInterceptors(
throw new RequiredError('{{.Name}}', 'Required parameter "{{.Name}}" was null or undefined when calling {{$.ID}}().');
}

{{end}} const headerParameters: Record<string, string> = {};
{{end}}{{with .MultipartBody}}{{range .Parts}}{{if .Binary}} if (!multipartContentTypeAllowed(requestParameters['{{.Name}}'].type, {{printf "%q" .AllowedContentType}})) {
throw new TypeError('Multipart part {{.Name}} content type "' + requestParameters['{{.Name}}'].type + '" is not allowed');
}

{{end}}{{end}}{{end}} const headerParameters: Record<string, string> = {};
{{if .HasSSE}} headerParameters['Accept'] = 'text/event-stream';
{{end}}{{if .BodyParamName}} headerParameters['Content-Type'] = 'application/json';
{{end}}{{if .RawBodyParamName}} headerParameters['Content-Type'] = {{printf "%q" .RawBodyMediaType}};
{{end}}{{with .MultipartBody}} const multipartBoundary = newMultipartBoundary();
headerParameters['Content-Type'] = {{printf "%q" .MediaType}} + '; boundary=' + multipartBoundary;
const multipartBody = new Blob([
{{range .Parts}} '--' + multipartBoundary + '\r\n',
{{if .Binary}} 'Content-Type: ' + requestParameters['{{.Name}}'].type + '\r\n\r\n',
requestParameters['{{.Name}}'],
{{else}} {{printf "%q" (printf "Content-Type: %s\r\n\r\n" .ContentType)}},
JSON.stringify(requestParameters['{{.Name}}']),
{{end}} '\r\n',
{{end}} '--' + multipartBoundary + '--\r\n',
]);
{{end}}{{if .QueryParams}} const queryParameters = new URLSearchParams();
{{range .QueryParams}}{{if .Required}}{{if .Slice}} for (const value of requestParameters['{{.Name}}']) {
queryParameters.append({{printf "%q" .WireName}}, String(value));
Expand All @@ -649,10 +691,13 @@ async function runInterceptors(
{{end}} }
}
{{end}}{{end}} const queryString = queryParameters.toString();
{{end}} return new Request(this.baseURL + {{.PathExpression}}{{if .QueryParams}} + (queryString === '' ? '' : '?' + queryString){{end}}, {
{{end}} const baseURL = {{if .ServerURL}}this.baseURLOverride ? this.baseURL : {{printf "%q" .ServerURL}}{{else}}this.baseURL{{end}};
return new Request(baseURL + {{.PathExpression}}{{if .QueryParams}} + (queryString === '' ? '' : '?' + queryString){{end}}, {
method: '{{.Method}}',
headers: headerParameters,
{{if .BodyParamName}} body: JSON.stringify(requestParameters['{{.BodyParamName}}']),
{{else if .RawBodyParamName}} body: requestParameters['{{.RawBodyParamName}}'],
{{else if .MultipartBody}} body: multipartBody,
{{end}} });
}

Expand Down
141 changes: 136 additions & 5 deletions internal/tsemit/tsemit.go
Original file line number Diff line number Diff line change
Expand Up @@ -187,9 +187,17 @@ func modelsTemplateData(doc *openapi.Document) modelsData {

func apiSource(doc *openapi.Document) (string, error) {
operations := doc.Operations()
for _, route := range operations {
if err := validateRequestBody(route.Operation); err != nil {
return "", err
}
}
data := apiData{
Imports: modelImports(doc, operations),
}
if len(doc.Servers) > 0 {
data.DefaultServerURL = strings.TrimRight(doc.Servers[0].URL, "/")
}
for _, route := range operations {
params := operationParams(route.Operation)
if len(params) > 0 {
Expand All @@ -202,14 +210,19 @@ func apiSource(doc *openapi.Document) (string, error) {
if operation.HasSSE {
data.HasSSE = true
}
if operation.MultipartBody != nil {
data.HasMultipartBody = true
}
data.Operations = append(data.Operations, operation)
}
return executeTemplate("api.ts", data)
}

type apiData struct {
Imports []string
DefaultServerURL string
HasSSE bool
HasMultipartBody bool
RequestInterfaces []requestInterfaceData
Operations []operationData
}
Expand All @@ -222,6 +235,7 @@ type requestInterfaceData struct {
type operationData struct {
ID string
Method string
ServerURL string
Params []opParam
RequiredParams []opParam
Responses []opResponse
Expand All @@ -235,16 +249,33 @@ type operationData struct {
PositionalRequestObject string
PathExpression string
BodyParamName string
RawBodyParamName string
RawBodyMediaType string
MultipartBody *tsMultipartBodyData
QueryParams []opParam
HasSSE bool
}

type tsMultipartBodyData struct {
MediaType string
Parts []tsMultipartPartData
}

type tsMultipartPartData struct {
Name string
Type string
JSON bool
Binary bool
ContentType string
AllowedContentType string
}

func operationTemplateData(doc *openapi.Document, route openapi.OperationRoute) operationData {
op := route.Operation
params := operationParams(op)
responses := operationResponses(doc, route.Method, op)
data := operationData{
ID: op.OperationID,
ID: openapi.LowerCamel(op.OperationID),
Method: route.Method,
Params: params,
Responses: responses,
Expand All @@ -258,6 +289,9 @@ func operationTemplateData(doc *openapi.Document, route openapi.OperationRoute)
PathExpression: pathExpression(route.Path, params),
HasSSE: doc.OperationHasSSEResponseMethod(route.Method, op),
}
if len(op.Servers) > 0 {
data.ServerURL = strings.TrimRight(op.Servers[0].URL, "/")
}
for _, param := range params {
if param.Required {
data.RequiredParams = append(data.RequiredParams, param)
Expand All @@ -272,8 +306,15 @@ func operationTemplateData(doc *openapi.Document, route openapi.OperationRoute)
}
}
if body := bodyParam(params); body != nil {
data.BodyParamName = body.Name
switch body.Kind {
case "body":
data.BodyParamName = body.Name
case "rawBody":
data.RawBodyParamName = body.Name
data.RawBodyMediaType, _ = op.RawRequestBodyMediaType()
}
}
data.MultipartBody, _ = sequentialMultipartBody(op)
return data
}

Expand Down Expand Up @@ -374,7 +415,17 @@ func operationParams(operation *openapi.Operation) []opParam {
Slice: param.Schema != nil && param.Schema.IsArray(),
})
}
if schema := operation.JSONRequestSchema(); schema != nil {
if multipartBody, ok := sequentialMultipartBody(operation); ok {
for _, part := range multipartBody.Parts {
params = append(params, opParam{
Name: part.Name,
WireName: part.Name,
Type: part.Type,
Required: true,
Kind: "multipart",
})
}
} else if schema := operation.JSONRequestSchema(); schema != nil {
name := openapi.LowerCamel(openapi.RefName(schema.Ref))
if name == "" {
name = "body"
Expand All @@ -383,13 +434,93 @@ func operationParams(operation *openapi.Operation) []opParam {
Name: name,
WireName: "body",
Type: tsType(schema),
Required: true,
Required: operation.RequestBody.Required,
Optional: optional(operation.RequestBody.Required),
Kind: "body",
})
} else if _, ok := operation.RawRequestBodyMediaType(); ok {
params = append(params, opParam{
Name: "body",
WireName: "body",
Type: "BodyInit",
Required: operation.RequestBody.Required,
Optional: optional(operation.RequestBody.Required),
Kind: "rawBody",
})
}
return params
}

func validateRequestBody(operation *openapi.Operation) error {
if operation.RequestBody == nil {
return nil
}
if media, ok := operation.RequestBody.Content["application/json"]; ok {
if media.Schema != nil {
return nil
}
return fmt.Errorf("operation %s has unsupported request body: application/json schema is missing", operation.OperationID)
}
if _, ok := sequentialMultipartBody(operation); ok {
return nil
}
if mediaType, ok := operation.RawRequestBodyMediaType(); ok && !strings.HasPrefix(mediaType, "multipart/") {
return nil
}
return fmt.Errorf("operation %s has unsupported request body", operation.OperationID)
}

func sequentialMultipartBody(operation *openapi.Operation) (*tsMultipartBodyData, bool) {
if operation.RequestBody == nil {
return nil, false
}
mediaTypes := make([]string, 0, len(operation.RequestBody.Content))
for mediaType := range operation.RequestBody.Content {
mediaTypes = append(mediaTypes, mediaType)
}
sort.Strings(mediaTypes)
for _, mediaType := range mediaTypes {
media := operation.RequestBody.Content[mediaType]
if !strings.HasPrefix(mediaType, "multipart/") || media.Schema == nil ||
!media.Schema.Type.Has("array") || len(media.Schema.PrefixItems) != 2 ||
len(media.PrefixEncoding) != 2 {
continue
}
minimum, maximum := 0, 0
if media.Schema.MinItems != nil {
minimum = *media.Schema.MinItems
}
if media.Schema.MaxItems != nil {
maximum = *media.Schema.MaxItems
}
if minimum != 2 || maximum != 2 {
continue
}
body := &tsMultipartBodyData{MediaType: mediaType}
for index, schema := range media.Schema.PrefixItems {
name := openapi.LowerCamel(schema.Title)
if name == "" {
name = fmt.Sprintf("part%d", index+1)
}
part := tsMultipartPartData{
Name: name,
ContentType: strings.TrimSpace(media.PrefixEncoding[index].ContentType),
}
if schema.Type.Has("string") && schema.Format == "binary" {
part.Binary = true
part.Type = "Blob"
part.AllowedContentType = part.ContentType
} else {
part.JSON = true
part.Type = tsType(schema)
}
body.Parts = append(body.Parts, part)
}
return body, true
}
return nil, false
}

func operationResponses(doc *openapi.Document, method string, operation *openapi.Operation) []opResponse {
var statuses []string
for status := range operation.Responses {
Expand Down Expand Up @@ -649,7 +780,7 @@ func pathExpression(path string, params []opParam) string {

func bodyParam(params []opParam) *opParam {
for _, param := range params {
if param.Kind == "body" {
if param.Kind == "body" || param.Kind == "rawBody" {
return &param
}
}
Expand Down
Loading
Loading