Skip to content
Draft
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: 9 additions & 1 deletion packages/core/src/codegen/generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -678,7 +678,15 @@ export const generateService = (
spec.memberTsType?.(info, tsRefFn) ??
(spec.extraBindings ?? []).find(
(b) => b.binding === info.binding && b.tsType !== undefined,
)?.tsType;
)?.tsType ??
// Whole-body binary payloads (`smithy.api#httpPayload` targeting
// `smithy.api#Blob`) accept every raw form the REST protocol sends
// verbatim (see buildRequest's rawBody branch); the JSON-flavor prelude
// would otherwise type them as a bare `string`, inviting JSON-corrupted
// uploads. Providers can still override via `memberTsType`.
(info.binding === "rawBody" && info.target === "smithy.api#Blob"
? "Blob | Uint8Array | ArrayBuffer | string"
: undefined);

const emitMember = (info: EmittedMember, selfIdx: number): string => {
let expr = ref(info.target, selfIdx);
Expand Down
66 changes: 64 additions & 2 deletions packages/core/src/codegen/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@
* as cookie params always are),
* body properties flattened alongside
* (labels win, then query, then headers, then body);
* non-object bodies become a sole `body` member with `smithy.api#httpPayload`
* non-object bodies become a sole `body` member with `smithy.api#httpPayload`;
* binary bodies (application/octet-stream, or an explicit
* `type: string, format: binary` schema) become a sole `smithy.api#Blob`
* payload member with the media type on the http trait's `bodyMediaType`
* • `$ref`s become NAMED shapes (`components/schemas/X` → `<ns>#X`), reused
* across operations; anonymous nested objects synthesize names from the
* parent + member path
Expand Down Expand Up @@ -196,6 +199,7 @@ const PRELUDE = {
Double: "smithy.api#Double",
Integer: "smithy.api#Integer",
Document: "smithy.api#Document",
Blob: "smithy.api#Blob",
} as const;

/** snake/kebab/space/camel → PascalCase identifier (inner caps preserved). */
Expand Down Expand Up @@ -883,6 +887,18 @@ const convertSchema = (
}
};

/**
* Whether a schema describes raw binary content: an explicit binary string
* (`type: string, format: binary` — OAS 3.0's file-upload idiom). Used only
* to classify request-body media types the JSON/form/multipart precedence
* didn't claim; a schema-less entry relies on its media type instead.
*/
const isBinarySchema = (ctx: Ctx, def: any): boolean => {
const r = deref(ctx, def);
if (!r || typeof r !== "object") return false;
return typeOf(r) === "string" && r.format === "binary";
};

/** Whether a property is a sensitive string (x-sensitive or name pattern). */
const isSensitiveProperty = (ctx: Ctx, name: string, def: any): boolean => {
const r = deref(ctx, def);
Expand Down Expand Up @@ -1315,10 +1331,12 @@ export const convertOpenApiToSmithy = (
}
}

// ---- Request body (json > form-urlencoded > multipart) ----
// ---- Request body (json > form-urlencoded > multipart > binary) ----
let contentType: "form-urlencoded" | "multipart" | undefined;
let bodySchema: any;
let bodyRequired = false;
let binaryMediaType: string | undefined;
let binaryBodyDoc: string | undefined;
if (version === "2.0") {
const bodyParam = params.find((p) => p.in === "body");
bodySchema = bodyParam?.schema;
Expand All @@ -1337,6 +1355,34 @@ export const convertOpenApiToSmithy = (
} else if (content["multipart/form-data"]) {
bodySchema = content["multipart/form-data"].schema;
contentType = "multipart";
} else {
// Binary request body (raw upload): `application/octet-stream`, or
// any other media type whose schema is an explicit binary string
// (`type: string, format: binary`). Becomes a sole Blob `body`
// member (`smithy.api#httpPayload`) sent VERBATIM under the
// declared media type — `bodyMediaType` on the `smithy.api#http`
// trait routes it down the REST protocol's raw-send path, since
// JSON-encoding file bytes would corrupt every upload.
const binary = Object.entries(content).find(
([mt, media]) =>
mt === "application/octet-stream" ||
isBinarySchema(ctx, (media as any)?.schema),
);
if (binary !== undefined) {
// A wildcard media type (`*/*`, `application/*`) is not a valid
// Content-Type to send — fall back to octet-stream.
binaryMediaType = binary[0].includes("*")
? "application/octet-stream"
: binary[0];
const schemaDoc = deref(ctx, (binary[1] as any)?.schema);
binaryBodyDoc =
(typeof rb?.description === "string"
? rb.description
: undefined) ??
(typeof schemaDoc?.description === "string"
? schemaDoc.description
: undefined);
}
}
}
if (bodySchema !== undefined) {
Expand Down Expand Up @@ -1379,6 +1425,19 @@ export const convertOpenApiToSmithy = (
});
}
}
} else if (binaryMediaType !== undefined) {
// Binary body (see the content-type precedence above) → sole Blob
// payload member; `bodyMediaType` rides the http trait below.
addMember("body", {
target: PRELUDE.Blob,
traits: {
"smithy.api#httpPayload": {},
...(bodyRequired ? { "smithy.api#required": {} } : {}),
...(binaryBodyDoc
? { "smithy.api#documentation": binaryBodyDoc }
: {}),
},
});
}

// ---- Input shape ----
Expand Down Expand Up @@ -1489,6 +1548,9 @@ export const convertOpenApiToSmithy = (
code: 200,
};
if (contentType === "multipart") httpTrait.contentType = "multipart";
if (binaryMediaType !== undefined) {
httpTrait.bodyMediaType = binaryMediaType;
}
const traits: Record<string, any> = { "smithy.api#http": httpTrait };
const documentation = opDoc(op);
if (documentation) traits["smithy.api#documentation"] = documentation;
Expand Down
85 changes: 60 additions & 25 deletions packages/core/src/protocol-http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
formDataFileSymbol,
getErrorMatchers,
headerSymbol,
hostLabelSymbol,
httpBodySymbol,
httpSymbol,
keyDictionarySymbol,
Expand Down Expand Up @@ -430,10 +431,14 @@ export const buildRequest = ({
| undefined;

const headers: Record<string, string> = { ...baseHeaders };
// The operation's declared Accept, when the API serves several media types
// (member-bound headers can still override it below).
if (http.accept !== undefined) headers["accept"] = http.accept;
const body: Record<string, unknown> = {};
let rawBody: unknown; // whole-body member (T.HttpBody) — sent as-is
const files: Array<Blob | File> = [];
const query = new URLSearchParams();
let base = baseUrl;
let uri = http.uri;
const consumed = new Set<string>();
let hasBodyMembers = false;
Expand All @@ -444,6 +449,7 @@ export const buildRequest = ({
const value = inputObj[key];
const isBodyMember =
!hasPropAnn(prop, labelSymbol) &&
!hasPropAnn(prop, hostLabelSymbol) &&
!hasPropAnn(prop, headerSymbol) &&
!hasPropAnn(prop, querySymbol) &&
!hasPropAnn(prop, deepQuerySymbol);
Expand All @@ -452,7 +458,20 @@ export const buildRequest = ({

if (hasPropAnn(prop, labelSymbol)) {
const token = nameOf(prop, labelSymbol);
uri = uri.replace(`{${token}}`, encodeURIComponent(String(value)));
// Greedy labels (`{token+}`, smithy's multi-segment form) keep their
// `/` separators, encoding each segment individually — path-like keys
// (S3/blob pathnames) would otherwise arrive with `%2F`.
uri = uri
.replace(
`{${token}+}`,
String(value).split("/").map(encodeURIComponent).join("/"),
)
.replace(`{${token}}`, encodeURIComponent(String(value)));
} else if (hasPropAnn(prop, hostLabelSymbol)) {
// Host labels fill `{token}` placeholders in the ENDPOINT template —
// raw, un-encoded (a template may take a whole origin as its label).
const token = nameOf(prop, hostLabelSymbol);
base = base.replaceAll(`{${token}}`, String(value));
} else if (hasPropAnn(prop, headerSymbol)) {
const hName = nameOf(prop, headerSymbol).toLowerCase();
const hVal = String(value);
Expand Down Expand Up @@ -506,7 +525,7 @@ export const buildRequest = ({
}

const qs = query.toString();
const url = `${baseUrl}${uri}${qs ? `?${qs}` : ""}`;
const url = `${base}${uri}${qs ? `?${qs}` : ""}`;
if (process.env.DISTILLED_DEBUG_HTTP) {
console.error(
`[distilled] ${http.method} ${url}` +
Expand Down Expand Up @@ -582,34 +601,33 @@ export const buildRequest = ({
);
} else if (rawBody !== undefined && !BODYLESS.has(http.method)) {
// Whole-body member (raw arrays/scalars) — sent as the body itself.
// Binary payloads (Blob / ArrayBuffer / Uint8Array) send verbatim
// (raw object uploads — the Content-Type header member, when modeled,
// rides alongside). With a bodyMediaType, the member is a
// preserialized payload (string / bytes) sent verbatim under that
// media type (e.g. application/x-ndjson for Vectorize
// insert/upsert); otherwise it's JSON.
// The effective media type is the modeled Content-Type HEADER member's
// value when one was set (per-call content types — blob/queue payload
// uploads), else the operation's static bodyMediaType (preserialized
// payloads, e.g. application/x-ndjson for Vectorize insert/upsert).
// The explicit type must ride ON THE BODY: `setBody` unconditionally
// rewrites the content-type header from the body metadata (and REMOVES
// it when the body carries none), so a header set beforehand would be
// silently dropped.
const mediaType = headers["content-type"] ?? http.bodyMediaType;
if (rawBody instanceof Blob || rawBody instanceof ArrayBuffer) {
// Honor the declared media type (e.g. application/x-ndjson for
// Vectorize insert/upsert) — without it the server may fall back to
// JSON parsing. When no bodyMediaType is modeled the header is left
// untouched (a modeled Content-Type header member rides alongside).
request = request.pipe(
HttpClientRequest.setBody(
HttpBody.raw(rawBody, { contentType: http.bodyMediaType }),
),
HttpClientRequest.setBody(HttpBody.raw(rawBody, { contentType: mediaType })),
);
} else if (rawBody instanceof Uint8Array) {
request = request.pipe(
HttpClientRequest.setBody(
HttpBody.uint8Array(rawBody, http.bodyMediaType),
),
HttpClientRequest.setBody(HttpBody.uint8Array(rawBody, mediaType)),
);
} else if (typeof rawBody === "string" && mediaType !== undefined) {
// A string payload with an explicit media type is preserialized
// content sent verbatim — JSON-quoting it would corrupt the upload.
request = request.pipe(
HttpClientRequest.setBody(HttpBody.text(rawBody, mediaType)),
);
} else if (http.bodyMediaType) {
request = request.pipe(
HttpClientRequest.setBody(
typeof rawBody === "string"
? HttpBody.text(rawBody, http.bodyMediaType)
: HttpBody.uint8Array(rawBody as Uint8Array, http.bodyMediaType),
HttpBody.uint8Array(rawBody as Uint8Array, http.bodyMediaType),
),
);
} else {
Expand Down Expand Up @@ -641,18 +659,32 @@ export const buildRequest = ({
/**
* Whether one matcher matches one wire error: every present field must
* match; a matcher (or a message object) with no constraints matches
* nothing.
* nothing. `header` matches when the named response header is present
* (case-insensitive; only checkable when the caller passes headers).
*/
export const matchesExpression = (
m: ErrorMatcher,
code: number | undefined,
status: number,
message: string,
headers?: Record<string, string | undefined>,
): boolean => {
if (m.code === undefined && m.status === undefined && m.message === undefined)
if (
m.code === undefined &&
m.status === undefined &&
m.message === undefined &&
m.header === undefined
)
return false;
if (m.code !== undefined && m.code !== code) return false;
if (m.status !== undefined && m.status !== status) return false;
if (m.header !== undefined) {
if (headers === undefined) return false;
const name = m.header.toLowerCase();
if (headers[name] === undefined && headers[m.header] === undefined) {
return false;
}
}
if (m.message !== undefined) {
if (typeof m.message === "string") {
if (m.message !== message) return false;
Expand All @@ -670,17 +702,20 @@ export const matchesExpression = (
const matcherSpecificity = (m: ErrorMatcher): number =>
(m.code !== undefined ? 1 : 0) +
(m.status !== undefined ? 1 : 0) +
(m.message !== undefined ? 1 : 0);
(m.message !== undefined ? 1 : 0) +
(m.header !== undefined ? 1 : 0);

/**
* Pick the operation's typed error class for a failed response: among all
* declared classes whose matchers (see `applyErrorMatchers`) match the wire
* failure, the most specific matcher wins (ties break by declaration order).
* `headers` (the response headers) enables `header`-presence matchers.
*/
export const matchTypedError = (
errorClasses: ReadonlyArray<unknown>,
status: number,
errors: ReadonlyArray<{ code?: number; message: string }>,
headers?: Record<string, string | undefined>,
): unknown | undefined => {
let best:
| { cls: unknown; specificity: number; code?: number; message: string }
Expand All @@ -690,7 +725,7 @@ export const matchTypedError = (
if (!matchers) continue;
for (const m of matchers) {
for (const e of errors) {
if (!matchesExpression(m, e.code, status, e.message)) continue;
if (!matchesExpression(m, e.code, status, e.message, headers)) continue;
const specificity = matcherSpecificity(m);
if (!best || specificity > best.specificity) {
best = { cls, specificity, code: e.code, message: e.message };
Expand Down
Loading
Loading