diff --git a/.chronus/changes/feature-js-emitter-2025-1-27-5-44-11.md b/.chronus/changes/feature-js-emitter-2025-1-27-5-44-11.md new file mode 100644 index 00000000000..d8566098e6a --- /dev/null +++ b/.chronus/changes/feature-js-emitter-2025-1-27-5-44-11.md @@ -0,0 +1,7 @@ +--- +changeKind: fix +packages: + - "@typespec/compiler" +--- + +Export missing type DiscriminatedUnionLegacy \ No newline at end of file diff --git a/.chronus/changes/feature-js-emitter-2025-1-27-5-45-10.md b/.chronus/changes/feature-js-emitter-2025-1-27-5-45-10.md new file mode 100644 index 00000000000..23c238f277e --- /dev/null +++ b/.chronus/changes/feature-js-emitter-2025-1-27-5-45-10.md @@ -0,0 +1,7 @@ +--- +changeKind: feature +packages: + - "@typespec/http-client" +--- + +Improvements to HttpClient context provider \ No newline at end of file diff --git a/.chronus/changes/feature-js-emitter-2025-1-27-6-2-18.md b/.chronus/changes/feature-js-emitter-2025-1-27-6-2-18.md new file mode 100644 index 00000000000..883d27386bf --- /dev/null +++ b/.chronus/changes/feature-js-emitter-2025-1-27-6-2-18.md @@ -0,0 +1,7 @@ +--- +changeKind: fix +packages: + - "@typespec/emitter-framework" +--- + +Improvements on the TestHarness \ No newline at end of file diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 1c4ef540924..366514ba33c 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -19,6 +19,11 @@ ###################### /packages/http-client-python/ @iscai-msft @tadelesh @msyyc +###################### +# JavaScript +###################### +/packages/http-client-js/ @joheredi + ###################### # Emiter Shared ###################### diff --git a/.prettierignore b/.prettierignore index cae241dd97f..55a8b6e6ed8 100644 --- a/.prettierignore +++ b/.prettierignore @@ -61,5 +61,12 @@ packages/http-client-csharp/generator/TestProjects/**/tspCodeModel.json packages/http-client-java/generator/http-client-generator-test/src/main/**/*.json packages/http-client-java/generator/http-client-generator-clientcore-test/src/main/**/*.json +# auto generated http-client-js files +packages/http-client-js/test/e2e/generated/ + +# built test files for http-client-js +packages/http-client-js/dist-test + + .gitattributes CODEOWNERS diff --git a/.prettierrc.json b/.prettierrc.json index 92f7d672970..4e084ea7bc3 100644 --- a/.prettierrc.json +++ b/.prettierrc.json @@ -21,13 +21,11 @@ } }, { - "files": ["packages/http-client/**/*.tsx"], - "options": { - "parser": "alloy-ts" - } - }, - { - "files": ["packages/emitter-framework/**/*.tsx"], + "files": [ + "packages/http-client-js/**/*.tsx", + "packages/http-client/**/*.tsx", + "packages/emitter-framework/**/*.tsx" + ], "options": { "parser": "alloy-ts" } diff --git a/cspell.yaml b/cspell.yaml index fea07b6a36a..d35a3222de4 100644 --- a/cspell.yaml +++ b/cspell.yaml @@ -279,6 +279,8 @@ ignorePaths: - packages/compiler/test/formatter/scenarios/** - packages/http-client-java/generator/http-client-generator-test/** - packages/http-client-java/generator/http-client-generator-clientcore-test/** + - packages/http-client-js/test/e2e/** + - packages/http-client-js/sample/** - pnpm-lock.yaml - "**/*.mp4" - "**/*.plist" diff --git a/eng/common/config/area.ts b/eng/common/config/area.ts index d355b3c2040..94b34b99a10 100644 --- a/eng/common/config/area.ts +++ b/eng/common/config/area.ts @@ -18,6 +18,7 @@ export const AreaPaths: Record = { "emitter:client:csharp": ["packages/http-client-csharp/"], "emitter:client:java": ["packages/http-client-java/"], "emitter:client:python": ["packages/http-client-python/"], + "emitter:client:js": ["packages/http-client-js/"], "emitter:graphql": ["packages/graphql/"], "emitter:json-schema": ["packages/json-schema/"], "emitter:protobuf": ["packages/protobuf/"], diff --git a/eng/common/config/labels.ts b/eng/common/config/labels.ts index c286c28473e..a4b78ee186d 100644 --- a/eng/common/config/labels.ts +++ b/eng/common/config/labels.ts @@ -63,6 +63,10 @@ export const AreaLabels = defineLabels({ color: "e1b300", description: "Issue for the Python client emitter: @typespec/http-client-python", }, + "emitter:client:js": { + color: "e1b300", + description: "Issue for the JavaScript client emitter: @typespec/http-client-js", + }, "emitter:graphql": { color: "957300", description: "Issues for @typespec/graphql emitter", diff --git a/eslint.config.js b/eslint.config.js index cb8b6901197..6c4755fcf98 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -120,6 +120,7 @@ const testFilesConfig = tsEslint.config({ const jsxFilesConfig = tsEslint.config({ files: ["**/*.tsx"], plugins: { "react-hooks": reactHooks }, + ignores: ["**/packages/http-client-js/**/*"], rules: { "react-hooks/rules-of-hooks": "error", "react-hooks/exhaustive-deps": "warn", @@ -138,6 +139,7 @@ export default tsEslint.config( { ignores: [ "**/dist/**/*", + "**/dist-test/**/*", "**/.temp/**/*", "**/temp/**/*", "**/generated-defs/*", @@ -146,6 +148,8 @@ export default tsEslint.config( "**/.docusaurus/**/*", "website/src/assets/**/*", "packages/compiler/templates/**/*", // Ignore the templates which might have invalid code and not follow exactly our rules. + "packages/http-client-js/test/e2e/generated", // Ignore the generated http client + "packages/http-client-js/sample/output/**/*", // Ignore the generated http client "**/venv/**/*", // Ignore python virtual env "**/.vscode-test-web/**/*", // Ignore VSCode test web project // TODO: enable diff --git a/packages/compiler/src/core/helpers/index.ts b/packages/compiler/src/core/helpers/index.ts index 1716befcd4b..4290f388144 100644 --- a/packages/compiler/src/core/helpers/index.ts +++ b/packages/compiler/src/core/helpers/index.ts @@ -1,4 +1,8 @@ -export { DiscriminatedUnion, getDiscriminatedUnion } from "./discriminator-utils.js"; +export { + DiscriminatedUnion, + DiscriminatedUnionLegacy, + getDiscriminatedUnion, +} from "./discriminator-utils.js"; export { getLocationContext } from "./location-context.js"; export * from "./operation-utils.js"; export * from "./path-interpolation.js"; diff --git a/packages/compiler/src/experimental/typekit/kits/model.ts b/packages/compiler/src/experimental/typekit/kits/model.ts index b210c0d9606..e1a28170ae4 100644 --- a/packages/compiler/src/experimental/typekit/kits/model.ts +++ b/packages/compiler/src/experimental/typekit/kits/model.ts @@ -105,6 +105,17 @@ export interface ModelKit { model: Model, options?: { includeExtended?: boolean }, ): RekeyableMap; + /** + * Get the record representing additional properties, if there are additional properties. + * This method checks for additional properties in the following cases: + * 1. If the model is a Record type. + * 2. If the model extends a Record type. + * 3. If the model spreads a Record type. + * + * @param model The model to get the additional properties type of. + * @returns The record representing additional properties, or undefined if there are none. + */ + getAdditionalPropertiesRecord(model: Model): Model | undefined; } interface TypekitExtension { @@ -198,9 +209,31 @@ defineKit({ base = base.baseModel; } } - // TODO: Add Spread? return properties; }, + getAdditionalPropertiesRecord(model) { + // model MyModel is Record<> {} should be model with additional properties + if (this.model.is(model) && model.sourceModel && this.record.is(model.sourceModel)) { + return model.sourceModel; + } + + // model MyModel extends Record<> {} should be model with additional properties + if (model.baseModel && this.record.is(model.baseModel)) { + return model.baseModel; + } + + // model MyModel { ...Record<>} should be model with additional properties + const spread = this.model.getSpreadType(model); + if (spread && this.model.is(spread) && this.record.is(spread)) { + return spread; + } + + if (model.baseModel) { + return this.model.getAdditionalPropertiesRecord(model.baseModel); + } + + return undefined; + }, }, }); diff --git a/packages/emitter-framework/package.json b/packages/emitter-framework/package.json index d639917a804..d2e6f6e1a2a 100644 --- a/packages/emitter-framework/package.json +++ b/packages/emitter-framework/package.json @@ -6,6 +6,8 @@ "scripts": { "build-src": "babel src -d dist/src --extensions .ts,.tsx", "build": "tsc -p . && npm run build-src", + "clean": "rimraf ./dist", + "format": "prettier . --write", "watch-src": "babel src -d dist/src --extensions .ts,.tsx --watch", "watch-tsc": "tsc -p . --watch", "watch": "concurrently --kill-others \"npm run watch-tsc\" \"npm run watch-src\"", diff --git a/packages/emitter-framework/src/testing/scenario-test/harness.ts b/packages/emitter-framework/src/testing/scenario-test/harness.ts index 059d22f3439..183a261b62a 100644 --- a/packages/emitter-framework/src/testing/scenario-test/harness.ts +++ b/packages/emitter-framework/src/testing/scenario-test/harness.ts @@ -271,36 +271,34 @@ function parseScenario( ); for (const line of rawLines) { - if (line.startsWith("```")) { - if (currentCodeBlock) { - // Close the code block - scenario.lines.push(currentCodeBlock); - if (!isTestCodeBlock(currentCodeBlock)) { - scenario.specBlock.content = currentCodeBlock.content; - } else { - for (const [template, fn] of Object.entries(outputCodeBlockTypes)) { - const templateRegex = new RegExp( - "^" + template.replace(/\{(\w+)\}/g, "(?<$1>[^\\s]+)") + "$", - ); - - const match = currentCodeBlock.heading.match(templateRegex); - if (match) { - currentCodeBlock.matchedTemplate = { - template, - fn, - namedArgs: match.groups ?? null, - }; - break; - } + if (line.startsWith("```") && currentCodeBlock) { + // Close the code block + scenario.lines.push(currentCodeBlock); + if (!isTestCodeBlock(currentCodeBlock)) { + scenario.specBlock.content = currentCodeBlock.content; + } else { + for (const [template, fn] of Object.entries(outputCodeBlockTypes)) { + const templateRegex = new RegExp( + "^" + template.replace(/\{(\w+)\}/g, "(?<$1>[^\\s]+)") + "$", + ); + + const match = currentCodeBlock.heading.match(templateRegex); + if (match) { + currentCodeBlock.matchedTemplate = { + template, + fn, + namedArgs: match.groups ?? null, + }; + break; } - scenario.testBlocks.push(currentCodeBlock); } - currentCodeBlock = null; - } else { - const codeBlockKind = line.includes("tsp") || line.includes("typespec") ? "spec" : "test"; - // Start a new code block - currentCodeBlock = { kind: codeBlockKind, heading: line.substring(3), content: [] }; + scenario.testBlocks.push(currentCodeBlock); } + currentCodeBlock = null; + } else if (line.startsWith("```")) { + const codeBlockKind = line.includes("tsp") || line.includes("typespec") ? "spec" : "test"; + // Start a new code block + currentCodeBlock = { kind: codeBlockKind, heading: line.substring(3), content: [] }; } else if (currentCodeBlock) { // Append to code block content currentCodeBlock.content.push(line); diff --git a/packages/emitter-framework/src/typescript/components/interface-declaration.tsx b/packages/emitter-framework/src/typescript/components/interface-declaration.tsx index 8e8aa2f7d2a..fc3c6ad747f 100644 --- a/packages/emitter-framework/src/typescript/components/interface-declaration.tsx +++ b/packages/emitter-framework/src/typescript/components/interface-declaration.tsx @@ -1,4 +1,4 @@ -import { Children, code, refkey as getRefkey, mapJoin } from "@alloy-js/core"; +import { Children, refkey as getRefkey, mapJoin } from "@alloy-js/core"; import * as ts from "@alloy-js/typescript"; import { Interface, Model, ModelProperty, Operation, RekeyableMap } from "@typespec/compiler"; import { $ } from "@typespec/compiler/experimental/typekit"; @@ -84,14 +84,13 @@ function getExtendsType(type: Model | Interface): Children | undefined { const extending: Children[] = []; - const recordExtends = code`Record`; - if (type.baseModel) { if ($.array.is(type.baseModel)) { extending.push(); } else if ($.record.is(type.baseModel)) { - extending.push(recordExtends); - // When extending a record we need to override the element type to be unknown to avoid type errors + // Here we are in the additional properties land. + // Instead of extending we need to create an envelope property + // do nothing here. } else { extending.push(getRefkey(type.baseModel)); } @@ -120,11 +119,15 @@ function membersFromType(type: Model | Interface) { let typeMembers: RekeyableMap | undefined; if ($.model.is(type)) { typeMembers = $.model.getProperties(type); - const spread = $.model.getSpreadType(type); - if (spread && $.model.is(spread) && $.record.is(spread)) { + const additionalProperties = $.model.getAdditionalPropertiesRecord(type); + if (additionalProperties) { typeMembers.set( "additionalProperties", - $.modelProperty.create({ name: "additionalProperties", optional: true, type: spread }), + $.modelProperty.create({ + name: "additionalProperties", + optional: true, + type: additionalProperties, + }), ); } } else { diff --git a/packages/emitter-framework/src/typescript/components/interface-member.tsx b/packages/emitter-framework/src/typescript/components/interface-member.tsx index 7c2b74ba76e..2b3996d8459 100644 --- a/packages/emitter-framework/src/typescript/components/interface-member.tsx +++ b/packages/emitter-framework/src/typescript/components/interface-member.tsx @@ -15,7 +15,7 @@ export function InterfaceMember({ type, optional }: InterfaceMemberProps) { const name = namer.getName(type.name, "object-member-getter"); if ($.modelProperty.is(type)) { - const optionality = (type.optional ?? optional) ? "?" : ""; + const optionality = optional === true || type.optional === true ? "?" : ""; if (isNeverType(type.type)) { return null; diff --git a/packages/emitter-framework/src/typescript/components/static-serializers.tsx b/packages/emitter-framework/src/typescript/components/static-serializers.tsx index c383f2195ac..3e719914e06 100644 --- a/packages/emitter-framework/src/typescript/components/static-serializers.tsx +++ b/packages/emitter-framework/src/typescript/components/static-serializers.tsx @@ -3,11 +3,11 @@ import * as ts from "@alloy-js/typescript"; export const DateRfc3339SerializerRefkey = refkey(); export function DateRfc3339Serializer() { - return - date?: Date + return + date?: Date | null {code` if (!date) { - return undefined; + return date as any } return date.toISOString(); @@ -17,11 +17,11 @@ export function DateRfc3339Serializer() { export const DateRfc7231SerializerRefkey = refkey(); export function DateRfc7231Serializer() { - return - date?: Date + return + date?: Date | null {code` if (!date) { - return undefined; + return date as any; } return date.toUTCString(); @@ -31,11 +31,11 @@ export function DateRfc7231Serializer() { export const DateDeserializerRefkey = refkey(); export function DateDeserializer() { - return - date?: string + return + date?: string | null {code` if (!date) { - return undefined; + return date as any; } return new Date(date); @@ -45,11 +45,11 @@ export function DateDeserializer() { export const DateUnixTimestampDeserializerRefkey = refkey(); export function DateUnixTimestampDeserializer() { - return - date?: number + return + date?: number | null {code` if (!date) { - return undefined; + return date as any; } return new Date(date * 1000); @@ -59,11 +59,11 @@ export function DateUnixTimestampDeserializer() { export const DateRfc7231DeserializerRefkey = refkey(); export function DateRfc7231Deserializer() { - return - date?: string + return + date?: string | null {code` if (!date) { - return undefined; + return date as any; } return new Date(date); @@ -73,11 +73,11 @@ export function DateRfc7231Deserializer() { export const DateUnixTimestampSerializerRefkey = refkey(); export function DateUnixTimestampSerializer() { - return - date?: Date + return + date?: Date | null {code` if (!date) { - return undefined; + return date as any; } return Math.floor(date.getTime() / 1000); @@ -87,13 +87,13 @@ export function DateUnixTimestampSerializer() { export const RecordSerializerRefkey = refkey(); export function RecordSerializer() { - const recordType = `Record | undefined`; + const recordType = `Record`; const convertFnType = `(item: any) => any`; return record?: {recordType}, convertFn?: {convertFnType} {code` if (!record) { - return undefined; + return record as any; } const output: Record = {}; @@ -111,13 +111,13 @@ export function RecordSerializer() { export const ArraySerializerRefkey = refkey(); export function ArraySerializer() { - const arrayType = `any[] | undefined`; + const arrayType = `any[]`; const convertFnType = `(item: any) => any`; return items?: {arrayType}, convertFn?: {convertFnType} {code` if (!items) { - return undefined; + return items as any; } const output: any[] = []; diff --git a/packages/emitter-framework/src/typescript/components/type-transform.tsx b/packages/emitter-framework/src/typescript/components/type-transform.tsx index 392efb09b61..21d064c347f 100644 --- a/packages/emitter-framework/src/typescript/components/type-transform.tsx +++ b/packages/emitter-framework/src/typescript/components/type-transform.tsx @@ -2,6 +2,7 @@ import { Children, code, mapJoin, Refkey, refkey } from "@alloy-js/core"; import * as ts from "@alloy-js/typescript"; import { Discriminator, + getDiscriminatedUnion, Model, ModelProperty, RekeyableMap, @@ -11,7 +12,6 @@ import { } from "@typespec/compiler"; import { $ } from "@typespec/compiler/experimental/typekit"; import { createRekeyableMap } from "@typespec/compiler/utils"; -import { getDiscriminatedUnion } from "../../../../compiler/dist/src/core/helpers/discriminator-utils.js"; import { reportDiagnostic } from "../../lib.js"; import { reportTypescriptDiagnostic } from "../../typescript/lib.js"; import { diff --git a/packages/http-client-js/.gitignore b/packages/http-client-js/.gitignore new file mode 100644 index 00000000000..5bed90c862b --- /dev/null +++ b/packages/http-client-js/.gitignore @@ -0,0 +1,4 @@ +test/e2e/generated +dist-test +.copilot +tsp-spector-coverage-javascript-standard.json diff --git a/packages/http-client-js/.testignore b/packages/http-client-js/.testignore new file mode 100644 index 00000000000..7a343d05cc0 --- /dev/null +++ b/packages/http-client-js/.testignore @@ -0,0 +1,2 @@ +# Shadowing issue +type/model/usage diff --git a/packages/http-client-js/README.md b/packages/http-client-js/README.md new file mode 100644 index 00000000000..b7c5f150ff5 --- /dev/null +++ b/packages/http-client-js/README.md @@ -0,0 +1,26 @@ +# Http Client JavaScript + +## Environment Variables + +### `TYPESPEC_JS_EMITTER_TESTING` + +This environment variable is used to enable testing-specific options in the TypeSpec JavaScript emitter. + +- **Name:** `TYPESPEC_JS_EMITTER_TESTING` +- **Type:** `boolean` +- **Default:** `false` +- **Description:** When set to `true`, enables testing-specific options in the TypeSpec JavaScript emitter. This is used to configure the client for testing purposes, such as allowing insecure connections and setting retry options. + +#### Usage + +To enable testing-specific options, set the environment variable before running your tests: + +```sh +export TYPESPEC_JS_EMITTER_TESTING=true +``` + +Or, if you are using a script, you can set it inline: + +```sh +TYPESPEC_JS_EMITTER_TESTING=true node your-script.js +``` diff --git a/packages/http-client-js/babel.config.js b/packages/http-client-js/babel.config.js new file mode 100644 index 00000000000..1c30974b369 --- /dev/null +++ b/packages/http-client-js/babel.config.js @@ -0,0 +1,4 @@ +export default { + sourceMaps: true, + presets: ["@babel/preset-typescript", "@alloy-js/babel-preset"], +}; diff --git a/packages/http-client-js/eng/scripts/calculate-coverage.js b/packages/http-client-js/eng/scripts/calculate-coverage.js new file mode 100644 index 00000000000..cb65b722221 --- /dev/null +++ b/packages/http-client-js/eng/scripts/calculate-coverage.js @@ -0,0 +1,32 @@ +/* eslint-disable no-console */ + +import chalk from "chalk"; +import { readFile } from "fs/promises"; +import { dirname, join } from "path"; +import { fileURLToPath } from "url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const coverageFilePath = join(__dirname, "../..", "tsp-spector-coverage-javascript-standard.json"); + +export async function calculateCoverage() { + try { + console.log(chalk.blue(`Reading coverage file from: ${coverageFilePath}`)); + const data = await readFile(coverageFilePath, "utf8"); + + const coverage = JSON.parse(data); + + const results = coverage[0].results; + const totalTests = Object.keys(results).length; + const passedTests = Object.values(results).filter((status) => status === "pass").length; + + const coveragePercentage = (passedTests / totalTests) * 100; + + console.log(chalk.bold.gray(`Total Tests: ${totalTests}`)); + console.log(chalk.bold.green(`Passed Tests: ${passedTests}`)); + console.log(chalk.bold.green(`Coverage Percentage: ${coveragePercentage.toFixed(2)}%`)); + } catch (error) { + console.error(chalk.red("Error calculating coverage:"), error); + } +} diff --git a/packages/http-client-js/eng/scripts/emit-e2e.js b/packages/http-client-js/eng/scripts/emit-e2e.js new file mode 100644 index 00000000000..792c3207daa --- /dev/null +++ b/packages/http-client-js/eng/scripts/emit-e2e.js @@ -0,0 +1,320 @@ +#!/usr/bin/env node +/* eslint-disable no-console */ +import chalk from "chalk"; +import { execa } from "execa"; +import pkg from "fs-extra"; +import { copyFile, mkdir, rm } from "fs/promises"; +import { globby } from "globby"; +import inquirer from "inquirer"; +import ora from "ora"; +import pLimit from "p-limit"; +import { basename, dirname, join, resolve } from "path"; +import { fileURLToPath } from "url"; +import { hideBin } from "yargs/helpers"; +import yargs from "yargs/yargs"; + +const { pathExists, stat, readFile, writeFile } = pkg; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const projectRoot = join(__dirname, "../.."); +const tspConfig = join(__dirname, "tspconfig.yaml"); + +const basePath = join(projectRoot, "node_modules", "@typespec", "http-specs", "specs"); +const ignoreFilePath = join(projectRoot, ".testignore"); +const logDirRoot = join(projectRoot, "temp", "emit-e2e-logs"); +const reportFilePath = join(logDirRoot, "report.txt"); + +// Remove the log directory if it exists. +async function clearLogDirectory() { + if (await pathExists(logDirRoot)) { + await rm(logDirRoot, { recursive: true, force: true }); + } +} + +// Parse command-line arguments. +const argv = yargs(hideBin(process.argv)) + .option("main-only", { + type: "boolean", + describe: "Use only main.tsp, even if client.tsp is found", + default: false, + }) + .option("interactive", { + type: "boolean", + describe: "Enable interactive mode", + default: false, + }) + .positional("paths", { + describe: "Optional list of specific file or directory paths to process (relative to basePath)", + type: "string", + array: true, + default: [], + }) + .option("build", { + type: "boolean", + describe: "Build the generated projects", + default: false, + }) + .help().argv; + +// Read and parse the ignore file. +async function getIgnoreList() { + try { + const content = await readFile(ignoreFilePath, "utf8"); + return content + .split(/\r?\n/) + .filter((line) => line.trim() && !line.startsWith("#")) + .map((line) => line.trim()); + } catch { + console.warn(chalk.yellow("No ignore file found.")); + return []; + } +} + +// Recursively process paths (files or directories relative to basePath). +async function processPaths(paths, ignoreList, mainOnly) { + const results = []; + for (const relativePath of paths) { + const fullPath = resolve(basePath, relativePath); + + if (!(await pathExists(fullPath))) { + console.warn(chalk.yellow(`Path not found: ${relativePath}`)); + continue; + } + + const stats = await stat(fullPath); + if (stats.isFile() && (fullPath.endsWith("client.tsp") || fullPath.endsWith("main.tsp"))) { + if (ignoreList.some((ignore) => relativePath.startsWith(ignore))) continue; + results.push({ fullPath, relativePath }); + } else if (stats.isDirectory()) { + const patterns = mainOnly ? ["**/main.tsp"] : ["**/client.tsp", "**/main.tsp"]; + const discoveredPaths = await globby(patterns, { cwd: fullPath }); + const validFiles = discoveredPaths + .map((p) => ({ + fullPath: join(fullPath, p), + relativePath: join(relativePath, p), + })) + .filter((file) => !ignoreList.some((ignore) => file.relativePath.startsWith(ignore))); + results.push(...validFiles); + } else { + console.warn(chalk.yellow(`Skipping unsupported path: ${relativePath}`)); + } + } + + // Deduplicate and prioritize client.tsp over main.tsp. + const filesByDir = new Map(); + for (const file of results) { + const dir = dirname(file.relativePath); + const existing = filesByDir.get(dir); + if (!existing || (!mainOnly && file.relativePath.endsWith("client.tsp"))) { + filesByDir.set(dir, file); + } + } + return Array.from(filesByDir.values()); +} + +// Run a shell command silently. +async function runCommand(command, args, options = {}) { + // Remove clutter by not printing anything; capture output by setting stdio to 'pipe'. + return await execa(command, args, { + stdio: "pipe", + env: { NODE_ENV: "test", TYPESPEC_JS_EMITTER_TESTING: "true", ...process.env }, + ...options, + }); +} + +// Process a single file. +async function processFile(file, options) { + const { fullPath, relativePath } = file; + const { build, interactive } = options; + const outputDir = join("test", "e2e", "generated", dirname(relativePath)); + const specCopyPath = join(outputDir, "spec.tsp"); + const logDir = join(projectRoot, "temp", "emit-e2e-logs", dirname(relativePath)); + + let spinner; + if (interactive) { + spinner = ora({ text: `Processing: ${relativePath}`, color: "cyan" }).start(); + } + + try { + if (await pathExists(outputDir)) { + if (spinner) spinner.text = `Clearing directory: ${outputDir}`; + await rm(outputDir, { recursive: true, force: true }); + } + if (spinner) spinner.text = `Creating directory: ${outputDir}`; + await mkdir(outputDir, { recursive: true }); + + if (spinner) spinner.text = `Copying spec to: ${specCopyPath}`; + await copyFile(fullPath, specCopyPath); + + if (spinner) spinner.text = `Compiling: ${relativePath}`; + await runCommand("npx", [ + "tsp", + "compile", + fullPath, + "--emit", + "@typespec/http-client-js", + "--config", + tspConfig, + "--output-dir", + outputDir, + ]); + + if (spinner) spinner.text = `Transpiling with Babel: ${relativePath}`; + await runCommand("npx", [ + "babel", + outputDir, + "-d", + `dist/${outputDir}`, + "--extensions", + ".ts,.tsx", + ]); + + if (spinner) spinner.text = `Formatting with Prettier: ${relativePath}`; + await runCommand("npx", ["prettier", outputDir, "--write"]); + + if (build) { + if (spinner) spinner.text = `Building project: ${relativePath}`; + await runCommand("npm", ["run", "build"], { cwd: outputDir }); + } + + if (spinner) { + spinner.succeed(`Finished processing: ${relativePath}`); + } + return { status: "succeeded", relativePath }; + } catch (error) { + if (spinner) { + spinner.fail(`Failed processing: ${relativePath}`); + } + const errorDetails = error.stdout || error.stderr || error.message; + + // Write error details to a log file. + await mkdir(logDir, { recursive: true }); + const logFilePath = join(logDir, `${basename(relativePath, ".tsp")}-error.log`); + await writeFile(logFilePath, errorDetails, "utf8"); + + if (interactive) { + const { action } = await inquirer.prompt([ + { + type: "list", + name: "action", + message: `Processing failed for ${relativePath}. What would you like to do?`, + choices: [ + { name: "Retry", value: "retry" }, + { name: "Skip to next file", value: "next" }, + { name: "Abort processing", value: "abort" }, + ], + }, + ]); + + if (action === "retry") { + if (spinner) spinner.start(`Retrying: ${relativePath}`); + return await processFile(file, options); + } else if (action === "next") { + console.log(chalk.yellow(`Skipping: ${relativePath}`)); + } else if (action === "abort") { + console.log(chalk.red("Aborting processing.")); + throw new Error("Processing aborted by user"); + } + } + return { status: "failed", relativePath, errorDetails }; + } +} + +// Process all files. +async function processFiles(files, options) { + const { interactive } = options; + const succeeded = []; + const failed = []; + + if (interactive) { + // Sequential processing so each spinner is visible. + for (const file of files) { + try { + const result = await processFile(file, options); + if (result.status === "succeeded") { + succeeded.push(result.relativePath); + } else { + failed.push({ relativePath: result.relativePath, errorDetails: result.errorDetails }); + } + } catch (err) { + break; + } + } + } else { + // Global progress spinner. + const total = files.length; + let completed = 0; + const globalSpinner = ora({ text: `Processing 0/${total} files...`, color: "cyan" }).start(); + const limit = pLimit(4); + const tasks = files.map((file) => + limit(() => + processFile(file, options).then((result) => { + completed++; + globalSpinner.text = `Processing ${completed}/${total} files...`; + return result; + }), + ), + ); + const results = await Promise.all(tasks); + globalSpinner.succeed(`Processed ${total} files`); + for (const result of results) { + if (result.status === "succeeded") { + succeeded.push(result.relativePath); + } else { + failed.push({ relativePath: result.relativePath, errorDetails: result.errorDetails }); + } + } + } + + console.log(chalk.bold.green("\nProcessing Complete:")); + console.log(chalk.green(`Succeeded: ${succeeded.length}`)); + console.log(chalk.red(`Failed: ${failed.length}`)); + + if (failed.length > 0) { + console.log(chalk.red("\nFailed Specs:")); + failed.forEach((f) => { + console.log(chalk.red(` - ${f.relativePath}`)); + }); + console.log(chalk.blue(`\nLogs available at: ${logDirRoot}`)); + } + + // Ensure the log directory exists before writing the report. + await mkdir(logDirRoot, { recursive: true }); + const report = [ + "Succeeded Files:", + ...succeeded.map((f) => ` - ${f}`), + "Failed Files:", + ...failed.map((f) => ` - ${f.relativePath}\n Error: ${f.errorDetails}`), + ].join("\n"); + await writeFile(reportFilePath, report, "utf8"); + console.log(chalk.blue(`Report written to: ${reportFilePath}`)); +} + +// Main logic. +(async () => { + const startTime = Date.now(); // Record start time + + await clearLogDirectory(); // Clear the log directory at the start. + + const ignoreList = await getIgnoreList(); + const paths = + argv._.length > 0 + ? await processPaths(argv._, ignoreList, argv["main-only"]) + : await processPaths(["."], ignoreList, argv["main-only"]); + + if (paths.length === 0) { + console.log(chalk.yellow("No files to process.")); + return; + } + + await processFiles(paths, { + interactive: argv.interactive, + build: argv.build, + }); + + const endTime = Date.now(); // Record end time + const duration = (endTime - startTime) / 1000; // Calculate duration in seconds + console.log(chalk.blue(`Total time taken: ${duration} seconds`)); // Log duration +})(); diff --git a/packages/http-client-js/eng/scripts/run-e2e-tests.js b/packages/http-client-js/eng/scripts/run-e2e-tests.js new file mode 100644 index 00000000000..4c309ff6cd1 --- /dev/null +++ b/packages/http-client-js/eng/scripts/run-e2e-tests.js @@ -0,0 +1,96 @@ +/* eslint-disable no-console */ +import chalk from "chalk"; +import { exec, spawn } from "child_process"; +import http from "http"; +import ora from "ora"; +import { promisify } from "util"; +import { calculateCoverage } from "./calculate-coverage.js"; + +const execPromise = promisify(exec); +const SERVER_URL = "http://localhost:3000/routes/in-interface/fixed"; // Endpoint to check if server is up and running +const spinner = ora(); +// Get test path argument from CLI (e.g., `npm run test:e2e my/test/path`) +const testPath = process.argv[2] || ""; // Default: Run all tests if no path is provided +const vitestArgs = ["run"]; +if (testPath) { + vitestArgs.push(testPath); +} else { + vitestArgs.push("test/e2e"); +} + +/** + * Waits until the mock server responds with HTTP 204 on /routes + */ +const waitForServer = async (url, retries = 20, delay = 2000) => { + for (let attempt = 1; attempt <= retries; attempt++) { + try { + const res = await new Promise((resolve, reject) => { + http.get(url, resolve).on("error", reject); + }); + + if (res.statusCode === 204) { + spinner.succeed(chalk.green(`โœ… Server is ready (received 204 from ${url})`)); + return; + } else { + console.log(chalk.yellow(`โš ๏ธ Attempt ${attempt}: Received ${res.statusCode}, retrying...`)); + } + } catch (error) { + console.log(chalk.gray(`๐Ÿ”„ Attempt ${attempt}: Server not ready yet...`)); + } + + await new Promise((resolve) => setTimeout(resolve, delay)); + } + + spinner.fail(chalk.red(`โŒ Server did not start in time (no 204 from ${url})`)); + throw new Error("Server did not start in time."); +}; + +/** + * Main function to start the server, run tests, and stop the server + */ +const main = async () => { + console.log(chalk.blue.bold("\n๐Ÿš€ Starting mock server...")); + spinner.start("Launching mock server..."); + + // Start the server using spawn to handle background processes + const serverProcess = spawn("npm", ["run", "start:server"], { + shell: true, + stdio: ["ignore", "pipe", "pipe"], + }); + + serverProcess.stdout.on("data", (data) => { + console.log(chalk.gray(`[Server] ${data.toString().trim()}`)); + }); + + serverProcess.stderr.on("data", (data) => { + console.error(chalk.red(`[Server Error] ${data.toString().trim()}`)); + }); + + try { + await waitForServer(SERVER_URL); + + console.log(chalk.green("\n๐Ÿงช Running tests...\n")); + console.log(chalk.cyan(`> vitest ${vitestArgs.join(" ")}`)); + + // Run Vitest with path argument (if provided) and stream output live + const testProcess = spawn("vitest", vitestArgs, { stdio: "inherit", shell: true }); + + testProcess.on("exit", async (code) => { + console.log(chalk.yellow("\n๐Ÿ›‘ Stopping server...")); + spinner.start("Shutting down mock server..."); + await execPromise("npm run stop:server"); + spinner.succeed(chalk.green("โœ… Mock server stopped successfully.")); + await calculateCoverage(); // Call calculateCoverage after the server is stopped + process.exit(code); + }); + } catch (err) { + console.error(chalk.red("\nโŒ Error:"), err.message); + await execPromise("npm run stop:server").catch(() => {}); + process.exit(1); + } finally { + serverProcess.kill(); // Ensure server stops even if something fails + } +}; + +await main(); +// await calculateCoverage(); // Call calculateCoverage after the server is stopped diff --git a/packages/http-client-js/eng/scripts/tspconfig.yaml b/packages/http-client-js/eng/scripts/tspconfig.yaml new file mode 100644 index 00000000000..3734bfa6b4c --- /dev/null +++ b/packages/http-client-js/eng/scripts/tspconfig.yaml @@ -0,0 +1,6 @@ +emitters: + "@typespec/http-client-js": true + +options: + "@typespec/http-client-js": + emitter-output-dir: "{output-dir}" diff --git a/packages/http-client-js/package.json b/packages/http-client-js/package.json new file mode 100644 index 00000000000..fe34b2b3802 --- /dev/null +++ b/packages/http-client-js/package.json @@ -0,0 +1,69 @@ +{ + "name": "@typespec/http-client-js", + "version": "0.1.0", + "type": "module", + "private": true, + "main": "dist/src/index.js", + "scripts": { + "build-src": "babel src -d dist/src --extensions .ts,.tsx", + "build": "tsc -p . && npm run build-src", + "clean": "rimraf ./dist", + "lint": "eslint . --max-warnings=0", + "lint:fix": "eslint . --fix", + "watch-src": "babel src -d dist/src --extensions .ts,.tsx --watch", + "watch-tsc": "tsc -p . --watch", + "watch": "concurrently --kill-others \"npm run watch-tsc\" \"npm run watch-src\"", + "test:scenarios": "vitest run scenarios.test.ts", + "test": "npm run test:scenarios", + "build-todo": "rimraf sample/output/todo dist/sample/output/todo && tsp compile sample/main.tsp --emit @typespec/http-client-js --output-dir sample/output/todo && babel sample/output/todo -d dist/sample/output/todo --extensions .ts,.tsx && npx prettier sample/output --write", + "format": "prettier . --write", + "build:test": "tsc -p tsconfig.test.json", + "start:server": "npx tsp-spector server start node_modules/@typespec/http-specs/specs --coverageFile ./tsp-spector-coverage-javascript-standard.json --debug", + "stop:server": "npx tsp-spector server stop", + "test:e2e": "npm run emit:e2e && node eng/scripts/run-e2e-tests.js", + "run:e2e": "node eng/scripts/run-e2e-tests.js", + "emit:e2e": "node eng/scripts/emit-e2e.js" + }, + "keywords": [], + "author": "", + "license": "ISC", + "description": "", + "peerDependencies": { + "@typespec/compiler": "workspace:~", + "@typespec/emitter-framework": "workspace:~", + "@typespec/http": "workspace:~", + "@typespec/http-client": "workspace:~", + "@typespec/rest": "workspace:~" + }, + "dependencies": { + "@alloy-js/core": "^0.5.0", + "@alloy-js/typescript": "^0.5.0", + "prettier": "~3.4.2" + }, + "devDependencies": { + "@alloy-js/babel-preset": "^0.1.1", + "@babel/cli": "^7.24.8", + "@babel/core": "^7.26.0", + "@rollup/plugin-babel": "^6.0.4", + "@types/yargs": "~17.0.33", + "@typespec/http-specs": "0.1.0-alpha.9", + "@typespec/spector": "workspace:~", + "@typespec/ts-http-runtime": "0.1.0", + "@typespec/versioning": "workspace:~", + "@vitest/ui": "^3.0.3", + "chalk": "^2.4.2", + "concurrently": "^9.1.2", + "dotenv": "^16.4.7", + "execa": "^9.5.2", + "fs-extra": "^11.2.0", + "globby": "~14.0.2", + "inquirer": "^12.2.0", + "ora": "^8.1.1", + "p-limit": "^6.2.0", + "typescript": "~5.7.3", + "uri-template": "^2.0.0", + "vitest": "^3.0.5", + "yargs": "~17.7.2", + "change-case": "~5.4.4" + } +} diff --git a/packages/http-client-js/sample/main.tsp b/packages/http-client-js/sample/main.tsp new file mode 100644 index 00000000000..758a4474428 --- /dev/null +++ b/packages/http-client-js/sample/main.tsp @@ -0,0 +1,296 @@ +import "@typespec/http"; +import "@typespec/rest"; +import "@typespec/openapi3"; +import "@typespec/openapi"; +import "@typespec/json-schema"; +using Http; +using JsonSchema; + +@service({ + title: "Todo App", +}) +@useAuth(BearerAuth | ApiKeyAuth) +@jsonSchema +namespace Todo; + +@jsonSchema +model User { + /** An autogenerated unique id for the user */ + @key + @visibility("read") + id: safeint; + + /** The user's username */ + @minLength(2) + @maxLength(50) + username: string; + + /** The user's email address */ + // @format("email") - crashes emitters for now + email: string; + + /** + * The user's password, provided when creating a user + * but is otherwise not visible (and hashed by the backend) + */ + @visibility("create") + password: string; + + /** Whether the user is validated. Never visible to the API. */ + @visibility("none") validated: boolean; +} + +@jsonSchema +model TodoItem { + /** The item's unique id */ + @visibility("read") @key id: safeint; + + /** The item's title */ + @maxLength(255) + title: string; + + /** User that created the todo */ + @visibility("read") createdBy: User.id; + + /** User that the todo is assigned to */ + assignedTo?: User.id; + + /** A longer description of the todo item in markdown format */ + description?: string; + + /** The status of the todo item */ + status: "NotStarted" | "InProgress" | "Completed"; + + /** When the todo item was created. */ + @visibility("read") createdAt: utcDateTime; + + /** When the todo item was last updated */ + @visibility("read") updatedAt: utcDateTime; + + /** When the todo item was makred as completed */ + @visibility("read") completedAt?: utcDateTime; + + // Want the read form to be normalized to TodoLabelRecord[], but can't + // https://github.com/microsoft/typespec/issues/2926 + labels?: TodoLabels; + + // hack to get a different schema for create + // (fastify glue doesn't support readonly) + @visibility("create") _dummy?: string; +} + +model ToDoItemMultipartRequest { + item: HttpPart; + attachments?: HttpPart[]; +} + +model FileAttachmentMultipartRequest { + contents: HttpPart; +} + +@jsonSchema +union TodoLabels { + string, + string[], + TodoLabelRecord, + TodoLabelRecord[], +} + +@jsonSchema +model TodoLabelRecord { + name: string; + + @pattern("^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$") + color?: string; +} + +@jsonSchema +model TodoAttachment { + /** The file name of the attachment */ + @maxLength(255) + filename: string; + + /** The media type of the attachment */ + mediaType: string; + + /** The contents of the file */ + contents: bytes; +} + +@jsonSchema +@error +model ApiError { + /** A machine readable error code */ + code: string; + + /** A human readable message */ + // https://github.com/microsoft/OpenAPI/blob/main/extensions/x-ms-primary-error-message.md + @OpenAPI.extension("x-ms-primary-error-message", true) + message: string; +} + +/** + * Something is wrong with you. + */ +model Standard4XXResponse extends ApiError { + @minValue(400) + @maxValue(499) + @statusCode + statusCode: int32; +} + +/** + * Something is wrong with me. + */ +model Standard5XXResponse extends ApiError { + @minValue(500) + @maxValue(599) + @statusCode + statusCode: int32; +} + +alias WithStandardErrors = T | Standard4XXResponse | Standard5XXResponse; + +@useAuth(NoAuth) +namespace Users { + // would prefer to extend + // https://github.com/microsoft/typespec/issues/2922 + + model UserCreatedResponse { + ...User; + ...OkResponse; + + /** The token to use to construct the validate email address url */ + token: string; + } + + /** The user already exists */ + model UserExistsResponse extends ApiError { + ...ConflictResponse; + code: "user-exists"; + } + + /** The user is invalid (e.g. forgot to enter email address) */ + model InvalidUserResponse extends ApiError { + @statusCode statusCode: 422; + code: "invalid-user"; + } + + @route("/users") + @post + op create( + @body user: User, + ): WithStandardErrors; +} + +@route("items") +namespace TodoItems { + model PaginationControls { + /** The limit to the number of items */ + @query limit?: int32 = 50; + + /** The offset to start paginating at */ + @query offset?: int32 = 0; + } + + model TodoPage { + /** The items in the page */ + @pageItems items: TodoItem[]; + + /** The number of items returned in this page */ + pageSize: int32; + + /** The total number of items */ + totalSize: int32; + + ...PaginationControls; + + /** A link to the previous page, if it exists */ + @prevLink + prevLink?: url; + + /** A link to the next page, if it exists */ + @nextLink + nextLink?: url; + } + + // deeply annoying that I have to copy/paste this... + model TodoItemPatch { + /** The item's title */ + title?: TodoItem.title; + + /** User that the todo is assigned to */ + assignedTo?: TodoItem.assignedTo | null; + + /** A longer description of the todo item in markdown format */ + description?: TodoItem.description | null; + + /** The status of the todo item */ + status?: "NotStarted" | "InProgress" | "Completed"; + } + + model InvalidTodoItem extends ApiError { + @statusCode statusCode: 422; + } + + @error + model NotFoundErrorResponse { + @statusCode statusCode: 404; + code: "not-found"; + } + + //@friendlyName("{name}List", T) + model Page { + @pageItems items: T[]; + } + + @list op list(...PaginationControls): WithStandardErrors; + + @sharedRoute + @post + op createJson( + @header contentType: "application/json", + item: TodoItem, + attachments?: TodoAttachment[], + ): WithStandardErrors; + + @sharedRoute + @post + op createForm( + @header contentType: "multipart/form-data", + @multipartBody body: ToDoItemMultipartRequest, + ): WithStandardErrors; + + @get op get(@path id: TodoItem.id): TodoItem | NotFoundErrorResponse; + @patch op update( + @header contentType: "application/merge-patch+json", + @path id: TodoItem.id, + @body patch: TodoItemPatch, + ): TodoItem; + @delete op delete( + @path id: TodoItem.id, + ): WithStandardErrors; + + @route("{itemId}/attachments") + namespace Attachments { + @list op list( + @path itemId: TodoItem.id, + ): WithStandardErrors | NotFoundErrorResponse>; + + @sharedRoute + @post + op createJsonAttachment( + @header contentType: "application/json", + @path itemId: TodoItem.id, + @body contents: TodoAttachment, + ): WithStandardErrors; + + @sharedRoute + @post + op createFileAttachment( + @header contentType: "multipart/form-data", + @path itemId: TodoItem.id, + @multipartBody body: FileAttachmentMultipartRequest, + ): WithStandardErrors; + } +} diff --git a/packages/http-client-js/sample/output/todo/@typespec/http-client-javascript/package.json b/packages/http-client-js/sample/output/todo/@typespec/http-client-javascript/package.json new file mode 100644 index 00000000000..cb7b7d73519 --- /dev/null +++ b/packages/http-client-js/sample/output/todo/@typespec/http-client-javascript/package.json @@ -0,0 +1,20 @@ +{ + "name": "test-package", + "version": "1.0.0", + "type": "module", + "dependencies": { + "@typespec/ts-http-runtime": "0.1.0", + "uri-template": "^2.0.0" + }, + "devDependencies": { + "@types/node": "~18.19.75", + "typescript": "^5.5.2" + }, + "scripts": { + "build": "tsc" + }, + "exports": { + ".": "./dist/index.js", + "./models": "./dist/models/index.js" + } +} diff --git a/packages/http-client-js/sample/output/todo/@typespec/http-client-javascript/src/api/todoClientContext.ts b/packages/http-client-js/sample/output/todo/@typespec/http-client-javascript/src/api/todoClientContext.ts new file mode 100644 index 00000000000..59986bd5eb7 --- /dev/null +++ b/packages/http-client-js/sample/output/todo/@typespec/http-client-javascript/src/api/todoClientContext.ts @@ -0,0 +1,18 @@ +import { Client, ClientOptions, KeyCredential, getClient } from "@typespec/ts-http-runtime"; + +export interface TodoClientContext extends Client {} +export interface TodoClientOptions extends ClientOptions { + endpoint?: string; +} +export function createTodoClientContext( + endpoint: string, + credential: KeyCredential, + options?: TodoClientOptions, +): TodoClientContext { + return getClient(endpoint, credential, { + ...options, + credentials: { + apiKeyHeaderName: "Authorization", + }, + }); +} diff --git a/packages/http-client-js/sample/output/todo/@typespec/http-client-javascript/src/api/todoItemsClient/attachmentsClient/attachmentsClientContext.ts b/packages/http-client-js/sample/output/todo/@typespec/http-client-javascript/src/api/todoItemsClient/attachmentsClient/attachmentsClientContext.ts new file mode 100644 index 00000000000..4a8279d9a2a --- /dev/null +++ b/packages/http-client-js/sample/output/todo/@typespec/http-client-javascript/src/api/todoItemsClient/attachmentsClient/attachmentsClientContext.ts @@ -0,0 +1,18 @@ +import { Client, ClientOptions, KeyCredential, getClient } from "@typespec/ts-http-runtime"; + +export interface AttachmentsClientContext extends Client {} +export interface AttachmentsClientOptions extends ClientOptions { + endpoint?: string; +} +export function createAttachmentsClientContext( + endpoint: string, + credential: KeyCredential, + options?: AttachmentsClientOptions, +): AttachmentsClientContext { + return getClient(endpoint, credential, { + ...options, + credentials: { + apiKeyHeaderName: "Authorization", + }, + }); +} diff --git a/packages/http-client-js/sample/output/todo/@typespec/http-client-javascript/src/api/todoItemsClient/attachmentsClient/attachmentsClientOperations.ts b/packages/http-client-js/sample/output/todo/@typespec/http-client-javascript/src/api/todoItemsClient/attachmentsClient/attachmentsClientOperations.ts new file mode 100644 index 00000000000..6e094d6990a --- /dev/null +++ b/packages/http-client-js/sample/output/todo/@typespec/http-client-javascript/src/api/todoItemsClient/attachmentsClient/attachmentsClientOperations.ts @@ -0,0 +1,100 @@ +import { parse } from "uri-template"; +import { OperationOptions } from "../../../helpers/interfaces.js"; +import { createFilePartDescriptor } from "../../../helpers/multipart-helpers.js"; +import { FileAttachmentMultipartRequest, Page, TodoAttachment } from "../../../models/models.js"; +import { + jsonPageToApplicationTransform, + jsonTodoAttachmentToTransportTransform, +} from "../../../models/serializers.js"; +import { AttachmentsClientContext } from "./attachmentsClientContext.js"; + +export interface ListOptions extends OperationOptions {} +export async function list( + client: AttachmentsClientContext, + itemId: number, + options?: ListOptions, +): Promise { + const path = parse("/items/{itemId}/attachments").expand({ + itemId: itemId, + }); + + const httpRequestOptions = { + headers: {}, + }; + + const response = await client.path(path).get(httpRequestOptions); + + if (typeof options?.operationOptions?.onResponse === "function") { + options?.operationOptions?.onResponse(response); + } + + if (+response.status === 200 && response.headers["content-type"]?.includes("application/json")) { + return jsonPageToApplicationTransform(response.body)!; + } + + throw new Error("Unhandled response"); +} +export interface CreateJsonAttachmentOptions extends OperationOptions { + contentType?: "application/json"; +} +export async function createJsonAttachment( + client: AttachmentsClientContext, + itemId: number, + contents: TodoAttachment, + options?: CreateJsonAttachmentOptions, +): Promise { + const path = parse("/items/{itemId}/attachments").expand({ + itemId: itemId, + }); + + const httpRequestOptions = { + headers: { + contentType: options?.contentType ?? "application/json", + }, + body: jsonTodoAttachmentToTransportTransform(contents), + }; + + const response = await client.path(path).post(httpRequestOptions); + + if (typeof options?.operationOptions?.onResponse === "function") { + options?.operationOptions?.onResponse(response); + } + + if (+response.status === 204 && !response.body) { + return; + } + + throw new Error("Unhandled response"); +} +export interface CreateFileAttachmentOptions extends OperationOptions { + contentType?: "multipart/form-data"; +} +export async function createFileAttachment( + client: AttachmentsClientContext, + itemId: number, + body: FileAttachmentMultipartRequest, + options?: CreateFileAttachmentOptions, +): Promise { + const path = parse("/items/{itemId}/attachments").expand({ + itemId: itemId, + }); + + const httpRequestOptions = { + headers: { + contentType: options?.contentType ?? "multipart/form-data", + }, + body: [createFilePartDescriptor("contents", body.contents)], + }; + + const response = await client.path(path).post(httpRequestOptions); + + if (typeof options?.operationOptions?.onResponse === "function") { + options?.operationOptions?.onResponse(response); + } + + if (+response.status === 204 && !response.body) { + return; + } + + throw new Error("Unhandled response"); +} diff --git a/packages/http-client-js/sample/output/todo/@typespec/http-client-javascript/src/api/todoItemsClient/todoItemsClientContext.ts b/packages/http-client-js/sample/output/todo/@typespec/http-client-javascript/src/api/todoItemsClient/todoItemsClientContext.ts new file mode 100644 index 00000000000..40b4e5ec3f4 --- /dev/null +++ b/packages/http-client-js/sample/output/todo/@typespec/http-client-javascript/src/api/todoItemsClient/todoItemsClientContext.ts @@ -0,0 +1,18 @@ +import { Client, ClientOptions, KeyCredential, getClient } from "@typespec/ts-http-runtime"; + +export interface TodoItemsClientContext extends Client {} +export interface TodoItemsClientOptions extends ClientOptions { + endpoint?: string; +} +export function createTodoItemsClientContext( + endpoint: string, + credential: KeyCredential, + options?: TodoItemsClientOptions, +): TodoItemsClientContext { + return getClient(endpoint, credential, { + ...options, + credentials: { + apiKeyHeaderName: "Authorization", + }, + }); +} diff --git a/packages/http-client-js/sample/output/todo/@typespec/http-client-javascript/src/api/todoItemsClient/todoItemsClientOperations.ts b/packages/http-client-js/sample/output/todo/@typespec/http-client-javascript/src/api/todoItemsClient/todoItemsClientOperations.ts new file mode 100644 index 00000000000..ecf881aae0c --- /dev/null +++ b/packages/http-client-js/sample/output/todo/@typespec/http-client-javascript/src/api/todoItemsClient/todoItemsClientOperations.ts @@ -0,0 +1,304 @@ +import { parse } from "uri-template"; +import { OperationOptions } from "../../helpers/interfaces.js"; +import { createFilePartDescriptor } from "../../helpers/multipart-helpers.js"; +import { + TodoAttachment, + TodoItem, + ToDoItemMultipartRequest, + TodoItemPatch, + TodoLabels, + TodoPage, +} from "../../models/models.js"; +import { + dateDeserializer, + jsonArrayTodoAttachmentToTransportTransform, + jsonTodoItemPatchToTransportTransform, + jsonTodoItemToTransportTransform, + jsonTodoLabelsToApplicationTransform, + jsonTodoLabelsToTransportTransform, + jsonTodoPageToApplicationTransform, +} from "../../models/serializers.js"; +import { TodoItemsClientContext } from "./todoItemsClientContext.js"; + +export interface ListOptions extends OperationOptions { + limit?: number; + offset?: number; +} +export async function list( + client: TodoItemsClientContext, + options?: ListOptions, +): Promise { + const path = parse("/items{?limit,offset}").expand({ + limit: options?.limit ?? 50, + offset: options?.offset, + }); + + const httpRequestOptions = { + headers: {}, + }; + + const response = await client.path(path).get(httpRequestOptions); + + if (typeof options?.operationOptions?.onResponse === "function") { + options?.operationOptions?.onResponse(response); + } + + if (+response.status === 200 && response.headers["content-type"]?.includes("application/json")) { + return jsonTodoPageToApplicationTransform(response.body)!; + } + + throw new Error("Unhandled response"); +} +export interface CreateJsonOptions extends OperationOptions { + contentType?: "application/json"; + assignedTo?: number; + description?: string; + labels?: TodoLabels; + _dummy?: string; + attachments?: Array; +} +export async function createJson( + client: TodoItemsClientContext, + item: TodoItem, + options?: CreateJsonOptions, +): Promise<{ + id: number; + title: string; + createdBy: number; + assignedTo?: number; + description?: string; + status: "NotStarted" | "InProgress" | "Completed"; + createdAt: Date; + updatedAt: Date; + completedAt?: Date; + labels?: TodoLabels; +}> { + const path = parse("/items").expand({}); + + const httpRequestOptions = { + headers: { + contentType: options?.contentType ?? "application/json", + }, + body: { + item: jsonTodoItemToTransportTransform(item), + attachments: jsonArrayTodoAttachmentToTransportTransform(options?.attachments), + }, + }; + + const response = await client.path(path).post(httpRequestOptions); + + if (typeof options?.operationOptions?.onResponse === "function") { + options?.operationOptions?.onResponse(response); + } + + if (+response.status === 200 && response.headers["content-type"]?.includes("application/json")) { + return { + id: response.body.id, + title: response.body.title, + createdBy: response.body.createdBy, + assignedTo: response.body.assignedTo, + description: response.body.description, + status: response.body.status, + createdAt: dateDeserializer(response.body.createdAt)!, + updatedAt: dateDeserializer(response.body.updatedAt)!, + completedAt: dateDeserializer(response.body.completedAt)!, + labels: jsonTodoLabelsToApplicationTransform(response.body.labels), + }!; + } + + throw new Error("Unhandled response"); +} +export interface CreateFormOptions extends OperationOptions { + contentType?: "multipart/form-data"; +} +export async function createForm( + client: TodoItemsClientContext, + body: ToDoItemMultipartRequest, + options?: CreateFormOptions, +): Promise<{ + id: number; + title: string; + createdBy: number; + assignedTo?: number; + description?: string; + status: "NotStarted" | "InProgress" | "Completed"; + createdAt: Date; + updatedAt: Date; + completedAt?: Date; + labels?: TodoLabels; +}> { + const path = parse("/items").expand({}); + + const httpRequestOptions = { + headers: { + contentType: options?.contentType ?? "multipart/form-data", + }, + body: [ + { + name: "item", + body: { + title: body.item.title, + assignedTo: body.item.assignedTo, + description: body.item.description, + status: body.item.status, + labels: jsonTodoLabelsToTransportTransform(body.item.labels), + _dummy: body.item._dummy, + }, + }, + ...(body.attachments ?? []).map((attachments: any) => + createFilePartDescriptor("attachments", attachments), + ), + ], + }; + + const response = await client.path(path).post(httpRequestOptions); + + if (typeof options?.operationOptions?.onResponse === "function") { + options?.operationOptions?.onResponse(response); + } + + if (+response.status === 200 && response.headers["content-type"]?.includes("application/json")) { + return { + id: response.body.id, + title: response.body.title, + createdBy: response.body.createdBy, + assignedTo: response.body.assignedTo, + description: response.body.description, + status: response.body.status, + createdAt: dateDeserializer(response.body.createdAt)!, + updatedAt: dateDeserializer(response.body.updatedAt)!, + completedAt: dateDeserializer(response.body.completedAt)!, + labels: jsonTodoLabelsToApplicationTransform(response.body.labels), + }!; + } + + throw new Error("Unhandled response"); +} +export interface GetOptions extends OperationOptions {} +export async function get( + client: TodoItemsClientContext, + id: number, + options?: GetOptions, +): Promise<{ + id: number; + title: string; + createdBy: number; + assignedTo?: number; + description?: string; + status: "NotStarted" | "InProgress" | "Completed"; + createdAt: Date; + updatedAt: Date; + completedAt?: Date; + labels?: TodoLabels; +}> { + const path = parse("/items/{id}").expand({ + id: id, + }); + + const httpRequestOptions = { + headers: {}, + }; + + const response = await client.path(path).get(httpRequestOptions); + + if (typeof options?.operationOptions?.onResponse === "function") { + options?.operationOptions?.onResponse(response); + } + + if (+response.status === 200 && response.headers["content-type"]?.includes("application/json")) { + return { + id: response.body.id, + title: response.body.title, + createdBy: response.body.createdBy, + assignedTo: response.body.assignedTo, + description: response.body.description, + status: response.body.status, + createdAt: dateDeserializer(response.body.createdAt)!, + updatedAt: dateDeserializer(response.body.updatedAt)!, + completedAt: dateDeserializer(response.body.completedAt)!, + labels: jsonTodoLabelsToApplicationTransform(response.body.labels), + }!; + } + + throw new Error("Unhandled response"); +} +export interface UpdateOptions extends OperationOptions { + contentType?: "application/merge-patch+json"; +} +export async function update( + client: TodoItemsClientContext, + id: number, + patch: TodoItemPatch, + options?: UpdateOptions, +): Promise<{ + id: number; + title: string; + createdBy: number; + assignedTo?: number; + description?: string; + status: "NotStarted" | "InProgress" | "Completed"; + createdAt: Date; + updatedAt: Date; + completedAt?: Date; + labels?: TodoLabels; +}> { + const path = parse("/items/{id}").expand({ + id: id, + }); + + const httpRequestOptions = { + headers: { + contentType: options?.contentType ?? "application/merge-patch+json", + }, + body: jsonTodoItemPatchToTransportTransform(patch), + }; + + const response = await client.path(path).patch(httpRequestOptions); + + if (typeof options?.operationOptions?.onResponse === "function") { + options?.operationOptions?.onResponse(response); + } + + if (+response.status === 200 && response.headers["content-type"]?.includes("application/json")) { + return { + id: response.body.id, + title: response.body.title, + createdBy: response.body.createdBy, + assignedTo: response.body.assignedTo, + description: response.body.description, + status: response.body.status, + createdAt: dateDeserializer(response.body.createdAt)!, + updatedAt: dateDeserializer(response.body.updatedAt)!, + completedAt: dateDeserializer(response.body.completedAt)!, + labels: jsonTodoLabelsToApplicationTransform(response.body.labels), + }!; + } + + throw new Error("Unhandled response"); +} +export interface DeleteOptions extends OperationOptions {} +export async function delete_( + client: TodoItemsClientContext, + id: number, + options?: DeleteOptions, +): Promise { + const path = parse("/items/{id}").expand({ + id: id, + }); + + const httpRequestOptions = { + headers: {}, + }; + + const response = await client.path(path).delete(httpRequestOptions); + + if (typeof options?.operationOptions?.onResponse === "function") { + options?.operationOptions?.onResponse(response); + } + + if (+response.status === 204 && !response.body) { + return; + } + + throw new Error("Unhandled response"); +} diff --git a/packages/http-client-js/sample/output/todo/@typespec/http-client-javascript/src/api/usersClient/usersClientContext.ts b/packages/http-client-js/sample/output/todo/@typespec/http-client-javascript/src/api/usersClient/usersClientContext.ts new file mode 100644 index 00000000000..3806c2efe9b --- /dev/null +++ b/packages/http-client-js/sample/output/todo/@typespec/http-client-javascript/src/api/usersClient/usersClientContext.ts @@ -0,0 +1,14 @@ +import { Client, ClientOptions, getClient } from "@typespec/ts-http-runtime"; + +export interface UsersClientContext extends Client {} +export interface UsersClientOptions extends ClientOptions { + endpoint?: string; +} +export function createUsersClientContext( + endpoint: string, + options?: UsersClientOptions, +): UsersClientContext { + return getClient(endpoint, { + ...options, + }); +} diff --git a/packages/http-client-js/sample/output/todo/@typespec/http-client-javascript/src/api/usersClient/usersClientOperations.ts b/packages/http-client-js/sample/output/todo/@typespec/http-client-javascript/src/api/usersClient/usersClientOperations.ts new file mode 100644 index 00000000000..99a4e765187 --- /dev/null +++ b/packages/http-client-js/sample/output/todo/@typespec/http-client-javascript/src/api/usersClient/usersClientOperations.ts @@ -0,0 +1,41 @@ +import { parse } from "uri-template"; +import { OperationOptions } from "../../helpers/interfaces.js"; +import { User } from "../../models/models.js"; +import { jsonUserToTransportTransform } from "../../models/serializers.js"; +import { UsersClientContext } from "./usersClientContext.js"; + +export interface CreateOptions extends OperationOptions {} +export async function create( + client: UsersClientContext, + user: User, + options?: CreateOptions, +): Promise<{ + id: number; + username: string; + email: string; + token: string; +}> { + const path = parse("/users").expand({}); + + const httpRequestOptions = { + headers: {}, + body: jsonUserToTransportTransform(user), + }; + + const response = await client.path(path).post(httpRequestOptions); + + if (typeof options?.operationOptions?.onResponse === "function") { + options?.operationOptions?.onResponse(response); + } + + if (+response.status === 200 && response.headers["content-type"]?.includes("application/json")) { + return { + id: response.body.id, + username: response.body.username, + email: response.body.email, + token: response.body.token, + }!; + } + + throw new Error("Unhandled response"); +} diff --git a/packages/http-client-js/sample/output/todo/@typespec/http-client-javascript/src/helpers/interfaces.ts b/packages/http-client-js/sample/output/todo/@typespec/http-client-javascript/src/helpers/interfaces.ts new file mode 100644 index 00000000000..92941327141 --- /dev/null +++ b/packages/http-client-js/sample/output/todo/@typespec/http-client-javascript/src/helpers/interfaces.ts @@ -0,0 +1,7 @@ +import { PathUncheckedResponse } from "@typespec/ts-http-runtime"; + +export interface OperationOptions { + operationOptions?: { + onResponse?: (rawResponse: PathUncheckedResponse) => void; + }; +} diff --git a/packages/http-client-js/sample/output/todo/@typespec/http-client-javascript/src/helpers/multipart-helpers.ts b/packages/http-client-js/sample/output/todo/@typespec/http-client-javascript/src/helpers/multipart-helpers.ts new file mode 100644 index 00000000000..7bbe70020ba --- /dev/null +++ b/packages/http-client-js/sample/output/todo/@typespec/http-client-javascript/src/helpers/multipart-helpers.ts @@ -0,0 +1,31 @@ +export interface File { + contents: FileContents; + contentType?: string; + filename?: string; +} +export type FileContents = + | string + | NodeJS.ReadableStream + | ReadableStream + | Uint8Array + | Blob; +export function createFilePartDescriptor( + partName: string, + fileInput: any, + defaultContentType?: string, +): any { + if (fileInput.contents) { + return { + name: partName, + body: fileInput.contents, + contentType: fileInput.contentType ?? defaultContentType, + filename: fileInput.filename, + }; + } else { + return { + name: partName, + body: fileInput, + contentType: defaultContentType, + }; + } +} diff --git a/packages/http-client-js/sample/output/todo/@typespec/http-client-javascript/src/index.ts b/packages/http-client-js/sample/output/todo/@typespec/http-client-javascript/src/index.ts new file mode 100644 index 00000000000..045c837747a --- /dev/null +++ b/packages/http-client-js/sample/output/todo/@typespec/http-client-javascript/src/index.ts @@ -0,0 +1,2 @@ +export * from "./models/index.js"; +export * from "./todoClient.js"; diff --git a/packages/http-client-js/sample/output/todo/@typespec/http-client-javascript/src/models/index.ts b/packages/http-client-js/sample/output/todo/@typespec/http-client-javascript/src/models/index.ts new file mode 100644 index 00000000000..b015b949874 --- /dev/null +++ b/packages/http-client-js/sample/output/todo/@typespec/http-client-javascript/src/models/index.ts @@ -0,0 +1,2 @@ +export * from "./models.js"; +export * from "./serializers.js"; diff --git a/packages/http-client-js/sample/output/todo/@typespec/http-client-javascript/src/models/models.ts b/packages/http-client-js/sample/output/todo/@typespec/http-client-javascript/src/models/models.ts new file mode 100644 index 00000000000..d7ebbba0dd6 --- /dev/null +++ b/packages/http-client-js/sample/output/todo/@typespec/http-client-javascript/src/models/models.ts @@ -0,0 +1,88 @@ +import { File as File_2 } from "../helpers/multipart-helpers.js"; + +export interface User { + id: number; + username: string; + email: string; + password: string; + validated: boolean; +} + +export type Safeint = number; + +export type Int64 = bigint; + +export type Integer = number; + +export type Numeric = number; + +export type String = string; + +export type Boolean = boolean; + +export interface Page { + items: Array; +} + +export interface TodoAttachment { + filename: string; + mediaType: string; + contents: Uint8Array; +} + +export type Bytes = Uint8Array; + +export interface FileAttachmentMultipartRequest { + contents: File; +} + +export type File = File_2; + +export type Int32 = number; + +export interface TodoPage { + items: Array; + pageSize: number; + totalSize: number; + limit?: number; + offset?: number; + prevLink?: string; + nextLink?: string; +} + +export interface TodoItem { + id: number; + title: string; + createdBy: number; + assignedTo?: number; + description?: string; + status: "NotStarted" | "InProgress" | "Completed"; + createdAt: Date; + updatedAt: Date; + completedAt?: Date; + labels?: TodoLabels; + _dummy?: string; +} + +export type UtcDateTime = Date; + +export type TodoLabels = string | Array | TodoLabelRecord | Array; + +export interface TodoLabelRecord { + name: string; + color?: string; +} + +export type Url = string; + +export interface ToDoItemMultipartRequest { + item: TodoItem; + attachments?: Array; +} + +export interface TodoItemPatch { + title?: string; + assignedTo?: number | null; + description?: string | null; + status?: "NotStarted" | "InProgress" | "Completed"; +} diff --git a/packages/http-client-js/sample/output/todo/@typespec/http-client-javascript/src/models/serializers.ts b/packages/http-client-js/sample/output/todo/@typespec/http-client-javascript/src/models/serializers.ts new file mode 100644 index 00000000000..93ec7c369e3 --- /dev/null +++ b/packages/http-client-js/sample/output/todo/@typespec/http-client-javascript/src/models/serializers.ts @@ -0,0 +1,497 @@ +import { + File, + FileAttachmentMultipartRequest, + Page, + TodoAttachment, + TodoItem, + ToDoItemMultipartRequest, + TodoItemPatch, + TodoLabelRecord, + TodoLabels, + TodoPage, + User, +} from "./models.js"; + +export function decodeBase64(value: string): Uint8Array | undefined { + if (!value) { + return undefined as any; + } + // Normalize Base64URL to Base64 + const base64 = value + .replace(/-/g, "+") + .replace(/_/g, "/") + .padEnd(value.length + ((4 - (value.length % 4)) % 4), "="); + + return new Uint8Array(Buffer.from(base64, "base64")); +} +export function encodeUint8Array( + value: Uint8Array | undefined, + encoding: BufferEncoding, +): string | undefined { + if (!value) { + return undefined; + } + return Buffer.from(value).toString(encoding); +} +export function dateDeserializer(date?: string): Date | undefined { + if (!date) { + return undefined; + } + + return new Date(date); +} +export function dateRfc7231Deserializer(date?: string): Date | undefined { + if (!date) { + return undefined; + } + + return new Date(date); +} +export function dateRfc3339Serializer(date?: Date): string | undefined { + if (!date) { + return undefined; + } + + return date.toISOString(); +} +export function dateRfc7231Serializer(date?: Date): string | undefined { + if (!date) { + return undefined; + } + + return date.toUTCString(); +} +export function dateUnixTimestampSerializer(date?: Date): number | undefined { + if (!date) { + return undefined; + } + + return Math.floor(date.getTime() / 1000); +} +export function dateUnixTimestampDeserializer(date?: number): Date | undefined { + if (!date) { + return undefined; + } + + return new Date(date * 1000); +} +export function updatePayloadToTransport(payload: TodoItemPatch) { + return jsonTodoItemPatchToTransportTransform(payload)!; +} +export function createJsonAttachmentPayloadToTransport(payload: TodoAttachment) { + return jsonTodoAttachmentToTransportTransform(payload)!; +} +export function createPayloadToTransport(payload: User) { + return jsonUserToTransportTransform(payload)!; +} + +export function jsonUserToTransportTransform(input_?: User): any { + if (!input_) { + return input_ as any; + } + + return { + id: input_.id, + username: input_.username, + email: input_.email, + password: input_.password, + validated: input_.validated, + }!; +} + +export function jsonUserToApplicationTransform(input_?: any): User { + if (!input_) { + return input_ as any; + } + + return { + id: input_.id, + username: input_.username, + email: input_.email, + password: input_.password, + validated: input_.validated, + }!; +} + +export function jsonPageToTransportTransform(input_?: Page): any { + if (!input_) { + return input_ as any; + } + + return { + items: jsonArrayTodoAttachmentToTransportTransform(input_.items), + }!; +} + +export function jsonPageToApplicationTransform(input_?: any): Page { + if (!input_) { + return input_ as any; + } + + return { + items: jsonArrayTodoAttachmentToApplicationTransform(input_.items), + }!; +} +export function jsonArrayTodoAttachmentToTransportTransform(items_?: Array): any { + if (!items_) { + return undefined as any; + } + const _transformedArray = []; + + for (const item of items_ ?? []) { + const transformedItem = jsonTodoAttachmentToTransportTransform(item as any); + _transformedArray.push(transformedItem); + } + + return _transformedArray as any; +} +export function jsonArrayTodoAttachmentToApplicationTransform(items_?: any): Array { + if (!items_) { + return undefined as any; + } + const _transformedArray = []; + + for (const item of items_ ?? []) { + const transformedItem = jsonTodoAttachmentToApplicationTransform(item as any); + _transformedArray.push(transformedItem); + } + + return _transformedArray as any; +} + +export function jsonTodoAttachmentToTransportTransform(input_?: TodoAttachment): any { + if (!input_) { + return input_ as any; + } + + return { + filename: input_.filename, + mediaType: input_.mediaType, + contents: input_.contents, + }!; +} + +export function jsonTodoAttachmentToApplicationTransform(input_?: any): TodoAttachment { + if (!input_) { + return input_ as any; + } + + return { + filename: input_.filename, + mediaType: input_.mediaType, + contents: input_.contents, + }!; +} + +export function jsonFileAttachmentMultipartRequestToTransportTransform( + input_?: FileAttachmentMultipartRequest, +): any { + if (!input_) { + return input_ as any; + } + + return { + contents: jsonFileToTransportTransform(input_.contents), + }!; +} + +export function jsonFileAttachmentMultipartRequestToApplicationTransform( + input_?: any, +): FileAttachmentMultipartRequest { + if (!input_) { + return input_ as any; + } + + return { + contents: jsonFileToApplicationTransform(input_.contents), + }!; +} + +export function jsonFileToTransportTransform(input_?: File): any { + if (!input_) { + return input_ as any; + } + + return { + contentType: input_.contentType, + filename: input_.filename, + contents: input_.contents, + }!; +} + +export function jsonFileToApplicationTransform(input_?: any): File { + if (!input_) { + return input_ as any; + } + + return { + contentType: input_.contentType, + filename: input_.filename, + contents: input_.contents, + }!; +} + +export function jsonTodoPageToTransportTransform(input_?: TodoPage): any { + if (!input_) { + return input_ as any; + } + + return { + items: jsonArrayTodoItemToTransportTransform(input_.items), + pageSize: input_.pageSize, + totalSize: input_.totalSize, + limit: input_.limit, + offset: input_.offset, + prevLink: input_.prevLink, + nextLink: input_.nextLink, + }!; +} + +export function jsonTodoPageToApplicationTransform(input_?: any): TodoPage { + if (!input_) { + return input_ as any; + } + + return { + items: jsonArrayTodoItemToApplicationTransform(input_.items), + pageSize: input_.pageSize, + totalSize: input_.totalSize, + limit: input_.limit, + offset: input_.offset, + prevLink: input_.prevLink, + nextLink: input_.nextLink, + }!; +} +export function jsonArrayTodoItemToTransportTransform(items_?: Array): any { + if (!items_) { + return undefined as any; + } + const _transformedArray = []; + + for (const item of items_ ?? []) { + const transformedItem = jsonTodoItemToTransportTransform(item as any); + _transformedArray.push(transformedItem); + } + + return _transformedArray as any; +} +export function jsonArrayTodoItemToApplicationTransform(items_?: any): Array { + if (!items_) { + return undefined as any; + } + const _transformedArray = []; + + for (const item of items_ ?? []) { + const transformedItem = jsonTodoItemToApplicationTransform(item as any); + _transformedArray.push(transformedItem); + } + + return _transformedArray as any; +} + +export function jsonTodoItemToTransportTransform(input_?: TodoItem): any { + if (!input_) { + return input_ as any; + } + + return { + id: input_.id, + title: input_.title, + createdBy: input_.createdBy, + assignedTo: input_.assignedTo, + description: input_.description, + status: input_.status, + createdAt: dateRfc3339Serializer(input_.createdAt), + updatedAt: dateRfc3339Serializer(input_.updatedAt), + completedAt: dateRfc3339Serializer(input_.completedAt), + labels: jsonTodoLabelsToTransportTransform(input_.labels), + _dummy: input_._dummy, + }!; +} + +export function jsonTodoItemToApplicationTransform(input_?: any): TodoItem { + if (!input_) { + return input_ as any; + } + + return { + id: input_.id, + title: input_.title, + createdBy: input_.createdBy, + assignedTo: input_.assignedTo, + description: input_.description, + status: input_.status, + createdAt: dateDeserializer(input_.createdAt)!, + updatedAt: dateDeserializer(input_.updatedAt)!, + completedAt: dateDeserializer(input_.completedAt)!, + labels: jsonTodoLabelsToApplicationTransform(input_.labels), + _dummy: input_._dummy, + }!; +} +export function jsonTodoLabelsToTransportTransform(input_?: TodoLabels): any { + if (!input_) { + return input_ as any; + } + return input_; +} + +export function jsonTodoLabelsToApplicationTransform(input_?: any): TodoLabels { + if (!input_) { + return input_ as any; + } + return input_; +} +export function jsonArrayStringToTransportTransform(items_?: Array): any { + if (!items_) { + return undefined as any; + } + const _transformedArray = []; + + for (const item of items_ ?? []) { + const transformedItem = item as any; + _transformedArray.push(transformedItem); + } + + return _transformedArray as any; +} +export function jsonArrayStringToApplicationTransform(items_?: any): Array { + if (!items_) { + return undefined as any; + } + const _transformedArray = []; + + for (const item of items_ ?? []) { + const transformedItem = item as any; + _transformedArray.push(transformedItem); + } + + return _transformedArray as any; +} + +export function jsonTodoLabelRecordToTransportTransform(input_?: TodoLabelRecord): any { + if (!input_) { + return input_ as any; + } + + return { + name: input_.name, + color: input_.color, + }!; +} + +export function jsonTodoLabelRecordToApplicationTransform(input_?: any): TodoLabelRecord { + if (!input_) { + return input_ as any; + } + + return { + name: input_.name, + color: input_.color, + }!; +} +export function jsonArrayTodoLabelRecordToTransportTransform(items_?: Array): any { + if (!items_) { + return undefined as any; + } + const _transformedArray = []; + + for (const item of items_ ?? []) { + const transformedItem = jsonTodoLabelRecordToTransportTransform(item as any); + _transformedArray.push(transformedItem); + } + + return _transformedArray as any; +} +export function jsonArrayTodoLabelRecordToApplicationTransform( + items_?: any, +): Array { + if (!items_) { + return undefined as any; + } + const _transformedArray = []; + + for (const item of items_ ?? []) { + const transformedItem = jsonTodoLabelRecordToApplicationTransform(item as any); + _transformedArray.push(transformedItem); + } + + return _transformedArray as any; +} + +export function jsonToDoItemMultipartRequestToTransportTransform( + input_?: ToDoItemMultipartRequest, +): any { + if (!input_) { + return input_ as any; + } + + return { + item: jsonTodoItemToTransportTransform(input_.item), + attachments: jsonArrayHttpPartToTransportTransform(input_.attachments), + }!; +} + +export function jsonToDoItemMultipartRequestToApplicationTransform( + input_?: any, +): ToDoItemMultipartRequest { + if (!input_) { + return input_ as any; + } + + return { + item: jsonTodoItemToApplicationTransform(input_.item), + attachments: jsonArrayHttpPartToApplicationTransform(input_.attachments), + }!; +} +export function jsonArrayHttpPartToTransportTransform(items_?: Array): any { + if (!items_) { + return undefined as any; + } + const _transformedArray = []; + + for (const item of items_ ?? []) { + const transformedItem = jsonFileToTransportTransform(item as any); + _transformedArray.push(transformedItem); + } + + return _transformedArray as any; +} +export function jsonArrayHttpPartToApplicationTransform(items_?: any): Array { + if (!items_) { + return undefined as any; + } + const _transformedArray = []; + + for (const item of items_ ?? []) { + const transformedItem = jsonFileToApplicationTransform(item as any); + _transformedArray.push(transformedItem); + } + + return _transformedArray as any; +} + +export function jsonTodoItemPatchToTransportTransform(input_?: TodoItemPatch): any { + if (!input_) { + return input_ as any; + } + + return { + title: input_.title, + assignedTo: input_.assignedTo, + description: input_.description, + status: input_.status, + }!; +} + +export function jsonTodoItemPatchToApplicationTransform(input_?: any): TodoItemPatch { + if (!input_) { + return input_ as any; + } + + return { + title: input_.title, + assignedTo: input_.assignedTo, + description: input_.description, + status: input_.status, + }!; +} diff --git a/packages/http-client-js/sample/output/todo/@typespec/http-client-javascript/src/todoClient.ts b/packages/http-client-js/sample/output/todo/@typespec/http-client-javascript/src/todoClient.ts new file mode 100644 index 00000000000..e366ea641ea --- /dev/null +++ b/packages/http-client-js/sample/output/todo/@typespec/http-client-javascript/src/todoClient.ts @@ -0,0 +1,123 @@ +import { KeyCredential } from "@typespec/ts-http-runtime"; +import { + TodoClientContext, + TodoClientOptions, + createTodoClientContext, +} from "./api/todoClientContext.js"; +import { + AttachmentsClientContext, + AttachmentsClientOptions, + createAttachmentsClientContext, +} from "./api/todoItemsClient/attachmentsClient/attachmentsClientContext.js"; +import { + CreateFileAttachmentOptions, + CreateJsonAttachmentOptions, + ListOptions as ListOptions_2, + createFileAttachment, + createJsonAttachment, + list as list_2, +} from "./api/todoItemsClient/attachmentsClient/attachmentsClientOperations.js"; +import { + TodoItemsClientContext, + TodoItemsClientOptions, + createTodoItemsClientContext, +} from "./api/todoItemsClient/todoItemsClientContext.js"; +import { + CreateFormOptions, + CreateJsonOptions, + DeleteOptions, + GetOptions, + ListOptions, + UpdateOptions, + createForm, + createJson, + delete_, + get, + list, + update, +} from "./api/todoItemsClient/todoItemsClientOperations.js"; +import { + UsersClientContext, + UsersClientOptions, + createUsersClientContext, +} from "./api/usersClient/usersClientContext.js"; +import { CreateOptions, create } from "./api/usersClient/usersClientOperations.js"; +import { + FileAttachmentMultipartRequest, + ToDoItemMultipartRequest, + TodoAttachment, + TodoItem, + TodoItemPatch, + User, +} from "./models/models.js"; + +export class TodoClient { + #context: TodoClientContext; + + constructor(endpoint: string, credential: KeyCredential, options?: TodoClientOptions) { + this.#context = createTodoClientContext(endpoint, credential, options); + } +} + +export class TodoItemsClient { + #context: TodoItemsClientContext; + attachmentsClient: AttachmentsClient; + constructor(endpoint: string, credential: KeyCredential, options?: TodoItemsClientOptions) { + this.#context = createTodoItemsClientContext(endpoint, credential, options); + this.attachmentsClient = new AttachmentsClient(endpoint, credential, options); + } + async list(options?: ListOptions) { + return list(this.#context, options); + } + async createJson(item: TodoItem, options?: CreateJsonOptions) { + return createJson(this.#context, item, options); + } + async createForm(body: ToDoItemMultipartRequest, options?: CreateFormOptions) { + return createForm(this.#context, body, options); + } + async get(id: number, options?: GetOptions) { + return get(this.#context, id, options); + } + async update(id: number, patch: TodoItemPatch, options?: UpdateOptions) { + return update(this.#context, id, patch, options); + } + async delete_(id: number, options?: DeleteOptions) { + return delete_(this.#context, id, options); + } +} + +export class AttachmentsClient { + #context: AttachmentsClientContext; + + constructor(endpoint: string, credential: KeyCredential, options?: AttachmentsClientOptions) { + this.#context = createAttachmentsClientContext(endpoint, credential, options); + } + async list(itemId: number, options?: ListOptions_2) { + return list_2(this.#context, itemId, options); + } + async createJsonAttachment( + itemId: number, + contents: TodoAttachment, + options?: CreateJsonAttachmentOptions, + ) { + return createJsonAttachment(this.#context, itemId, contents, options); + } + async createFileAttachment( + itemId: number, + body: FileAttachmentMultipartRequest, + options?: CreateFileAttachmentOptions, + ) { + return createFileAttachment(this.#context, itemId, body, options); + } +} + +export class UsersClient { + #context: UsersClientContext; + + constructor(endpoint: string, options?: UsersClientOptions) { + this.#context = createUsersClientContext(endpoint, options); + } + async create(user: User, options?: CreateOptions) { + return create(this.#context, user, options); + } +} diff --git a/packages/http-client-js/sample/output/todo/@typespec/http-client-javascript/tsconfig.json b/packages/http-client-js/sample/output/todo/@typespec/http-client-javascript/tsconfig.json new file mode 100644 index 00000000000..67770c35e4e --- /dev/null +++ b/packages/http-client-js/sample/output/todo/@typespec/http-client-javascript/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "nodenext", + "strict": true, + "declaration": true, + "sourceMap": true, + "declarationMap": true, + "outDir": "dist" + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/http-client-js/src/components/client-context/client-context-declaration.tsx b/packages/http-client-js/src/components/client-context/client-context-declaration.tsx new file mode 100644 index 00000000000..e956cd91c81 --- /dev/null +++ b/packages/http-client-js/src/components/client-context/client-context-declaration.tsx @@ -0,0 +1,19 @@ +import * as ay from "@alloy-js/core"; +import * as ts from "@alloy-js/typescript"; +import * as cl from "@typespec/http-client"; +import { httpRuntimeTemplateLib } from "../external-packages/ts-http-runtime.js"; + +export interface ClientContextDeclarationProps { + client: cl.Client; +} + +export function getClientcontextDeclarationRef(client: cl.Client) { + return ay.refkey(client, "declaration"); +} + +export function ClientContextDeclaration(props: ClientContextDeclarationProps) { + const ref = getClientcontextDeclarationRef(props.client); + const namePolicy = ts.useTSNamePolicy(); + const name = namePolicy.getName(`${props.client.name}Context`, "class"); + return ; +} diff --git a/packages/http-client-js/src/components/client-context/client-context-factory.tsx b/packages/http-client-js/src/components/client-context/client-context-factory.tsx new file mode 100644 index 00000000000..795db8f38d8 --- /dev/null +++ b/packages/http-client-js/src/components/client-context/client-context-factory.tsx @@ -0,0 +1,142 @@ +import * as ay from "@alloy-js/core"; +import * as ts from "@alloy-js/typescript"; +import { $ } from "@typespec/compiler/experimental/typekit"; +import { FunctionDeclaration } from "@typespec/emitter-framework/typescript"; +import * as cl from "@typespec/http-client"; +import { reportDiagnostic } from "../../lib.js"; +import { buildClientParameters } from "../../utils/parameters.jsx"; +import { httpRuntimeTemplateLib } from "../external-packages/ts-http-runtime.js"; +import { addClientTestOptions } from "../testing/client-options.jsx"; +import { getClientcontextDeclarationRef } from "./client-context-declaration.jsx"; +import { ParametrizedEndpoint } from "./parametrized-endpoint.jsx"; + +export interface ClientContextFactoryProps { + client: cl.Client; +} + +export function getClientContextFactoryRef(client: cl.Client) { + return ay.refkey(client, "contextFactory"); +} + +export function ClientContextFactoryDeclaration(props: ClientContextFactoryProps) { + const ref = getClientContextFactoryRef(props.client); + const contextDeclarationRef = getClientcontextDeclarationRef(props.client); + const namePolicy = ts.useTSNamePolicy(); + const factoryFunctionName = namePolicy.getName(`create_${props.client.name}Context`, "function"); + + const clientConstructor = $.client.getConstructor(props.client); + const parameters = buildClientParameters(props.client); + const urlTemplate = $.client.getUrlTemplate(props.client); + const endpointRef = ay.refkey(); + const resolvedEndpoint = + ; + + let credentialsRef: string | undefined; + if (clientConstructor.parameters.properties.has("credential")) { + credentialsRef = "credential"; + } + + // Filter out optional parameters, they will be passed as options + const args = + ; + + return + {resolvedEndpoint} + return +; +} + +interface ClientFactoryArgumentsProps { + client: cl.Client; + endpointRef: ay.Refkey; + credentialsRef?: string; +} + +function ClientFactoryArguments(props: ClientFactoryArgumentsProps) { + const params = [<>{props.endpointRef},]; + + if (props.credentialsRef) { + params.push(<>{props.credentialsRef},); + } + + return [ + ...params, + + + , + ]; +} + +interface CredentialOptionsProps { + client: cl.Client; +} + +function CredentialOptions(props: CredentialOptionsProps) { + const clientCredential = $.client.getAuth(props.client); + + if (clientCredential.schemes.length === 0) { + return null; + } + + if (clientCredential.schemes.length !== 1) { + reportDiagnostic($.program, { + code: "multiple-auth-schemes-not-yet-supported", + target: props.client.type, + }); + } + + const scheme = clientCredential.schemes[0]; + + switch (scheme.type) { + case "http": + // Todo: handle scopes? + return + + + + ; + case "apiKey": + if (scheme.in !== "header") { + reportDiagnostic($.program, { code: "non-model-parts", target: props.client.service }); + return null; + } + + return + + + + ; + default: + return null; + } +} + +interface ClientOptionsExpressionProps { + children?: ay.Children; +} + +function ClientOptionsExpression(props: ClientOptionsExpressionProps) { + const options: ay.Children = ["...options"]; + + // Conditionally add test options + // based on the environment variable TYPESPEC_JS_EMITTER_TESTING + addClientTestOptions(options); + + const children = Array.isArray(props.children) ? props.children : [props.children]; + if (children.length) { + const children = Array.isArray(props.children) ? props.children : [props.children]; + options.push(...children); + } + + return + {ay.mapJoin(options, (child) => child, { joiner: ", " })} + ; +} diff --git a/packages/http-client-js/src/components/client-context/client-context-options.tsx b/packages/http-client-js/src/components/client-context/client-context-options.tsx new file mode 100644 index 00000000000..cdb69c53969 --- /dev/null +++ b/packages/http-client-js/src/components/client-context/client-context-options.tsx @@ -0,0 +1,28 @@ +import * as ay from "@alloy-js/core"; +import * as ts from "@alloy-js/typescript"; +import * as cl from "@typespec/http-client"; +import { httpRuntimeTemplateLib } from "../external-packages/ts-http-runtime.js"; + +export interface ClientContextOptionsDeclarationProps { + client: cl.Client; +} + +export function getClientContextOptionsRef(client: cl.Client) { + return ay.refkey(client, "options"); +} + +export function ClientContextOptionsDeclaration(props: ClientContextOptionsDeclarationProps) { + const ref = getClientContextOptionsRef(props.client); + const namePolicy = ts.useTSNamePolicy(); + const name = namePolicy.getName(`${props.client.name}Options`, "interface"); + + // TODO: Here we will calculate and include all the options that the client can accept + const clientOptions: Map = new Map(); + + return }> + {ay.mapJoin(clientOptions, (key, value) => ( + + ), { joiner: ";\n" })} + + ; +} diff --git a/packages/http-client-js/src/components/client-context/client-context.tsx b/packages/http-client-js/src/components/client-context/client-context.tsx new file mode 100644 index 00000000000..5166014f8a1 --- /dev/null +++ b/packages/http-client-js/src/components/client-context/client-context.tsx @@ -0,0 +1,21 @@ +import * as ay from "@alloy-js/core"; +import * as ts from "@alloy-js/typescript"; +import * as cl from "@typespec/http-client"; +import { ClientContextDeclaration } from "./client-context-declaration.jsx"; +import { ClientContextFactoryDeclaration } from "./client-context-factory.jsx"; +import { ClientContextOptionsDeclaration } from "./client-context-options.jsx"; + +export interface ClientContextProps { + client: cl.Client; + children?: ay.Children; +} + +export function ClientContext(props: ClientContextProps) { + const namePolicy = ts.useTSNamePolicy(); + const fileName = namePolicy.getName(props.client.name + "Context", "variable"); + return + + + + ; +} diff --git a/packages/http-client-js/src/components/client-context/parametrized-endpoint.tsx b/packages/http-client-js/src/components/client-context/parametrized-endpoint.tsx new file mode 100644 index 00000000000..1ecb404b766 --- /dev/null +++ b/packages/http-client-js/src/components/client-context/parametrized-endpoint.tsx @@ -0,0 +1,39 @@ +import * as ay from "@alloy-js/core"; +import * as ts from "@alloy-js/typescript"; +import { ModelProperty } from "@typespec/compiler"; +import { useTransformNamePolicy } from "@typespec/emitter-framework"; +import { getDefaultValue } from "../../utils/parameters.jsx"; + +export interface ParametrizedEndpointProps { + refkey: ay.Refkey; + template: string; + params: ModelProperty[]; +} + +export function ParametrizedEndpoint(props: ParametrizedEndpointProps) { + const propNamer = useTransformNamePolicy(); + const paramsRef = ay.refkey(); + const params = + "} refkey={paramsRef}> + + {ay.mapJoin(props.params, (p) => { + const applicationName = propNamer.getApplicationName(p); + const transportName = propNamer.getTransportName(p); + const defaultValue = p.defaultValue ? ` ?? ${getDefaultValue(p)}` : ""; + const itemRef = p.optional ? `options?.${applicationName}${defaultValue}` : applicationName; + return + }, {joiner: ",\n"})} + + ; + + const resolvedEndpoint = + + {ay.code` + "${props.template}".replace(/{([^}]+)}/g, (_, key) => + key in ${paramsRef} ? String(params[key]) : (() => { throw new Error(\`Missing parameter: $\{key}\`); })() + ); + `} + ; + + return [params, resolvedEndpoint]; +} diff --git a/packages/http-client-js/src/components/client-directory.tsx b/packages/http-client-js/src/components/client-directory.tsx new file mode 100644 index 00000000000..431cb84a582 --- /dev/null +++ b/packages/http-client-js/src/components/client-directory.tsx @@ -0,0 +1,40 @@ +import * as ay from "@alloy-js/core"; +import * as ts from "@alloy-js/typescript"; +import * as cl from "@typespec/http-client"; +import { useClientLibrary } from "@typespec/http-client"; +import { ClientContext } from "./client-context/client-context.jsx"; +import { ClientOperations } from "./client-operation.jsx"; + +export interface OperationsDirectoryProps { + children?: ay.Children; +} + +export function OperationsDirectory(props: OperationsDirectoryProps) { + const { topLevel: clients } = useClientLibrary(); + // If it is the root client, we don't need to create a directory + return ay.mapJoin(clients, ( + client, + ) => <> + + + + ); +} + +export interface SubClientsProps { + client: cl.Client; +} + +export function SubClients(props: SubClientsProps) { + const subClients = props.client.subClients; + + return ay.mapJoin(subClients, (subClient) => { + const namePolicy = ts.useTSNamePolicy(); + const subClientName = namePolicy.getName(subClient.name, "variable"); + return + + + + ; + }); +} diff --git a/packages/http-client-js/src/components/client-operation.tsx b/packages/http-client-js/src/components/client-operation.tsx new file mode 100644 index 00000000000..db8eba44d15 --- /dev/null +++ b/packages/http-client-js/src/components/client-operation.tsx @@ -0,0 +1,52 @@ +import * as ay from "@alloy-js/core"; +import * as ts from "@alloy-js/typescript"; +import { $ } from "@typespec/compiler/experimental/typekit"; +import { FunctionDeclaration, TypeExpression } from "@typespec/emitter-framework/typescript"; +import * as cl from "@typespec/http-client"; +import { getClientcontextDeclarationRef } from "./client-context/client-context-declaration.jsx"; +import { HttpRequest } from "./http-request.jsx"; +import { HttpResponse } from "./http-response.jsx"; +import { OperationOptionsDeclaration } from "./operation-options.jsx"; +import { getOperationParameters } from "./operation-parameters.jsx"; + +export interface ClientOperationsProps { + client: cl.Client; +} + +export function ClientOperations(props: ClientOperationsProps) { + const namePolicy = ts.useTSNamePolicy(); + const clientOperations = props.client.operations; + const fileName = namePolicy.getName(props.client.name + "Operations", "variable"); + + if (clientOperations.length === 0) { + return null; + } + + return + {ay.mapJoin(clientOperations, (operation) => { + return ; + })} +; +} + +export interface ClientOperationProps { + clientOperation: cl.ClientOperation; +} + +export function ClientOperation(props: ClientOperationProps) { + const client = props.clientOperation.client; + const returnType = $.httpOperation.getReturnType(props.clientOperation.httpOperation); + const responseRefkey = ay.refkey(props.clientOperation, "http-response"); + const clientContextInterfaceRef = getClientcontextDeclarationRef(client); + const signatureParams: Record = { + client: { type: clientContextInterfaceRef, refkey: ay.refkey(client, "client") }, + ...getOperationParameters(props.clientOperation.httpOperation), + }; + return <> + + } parametersMode="replace" parameters={signatureParams}> + + + ; + ; +} diff --git a/packages/http-client-js/src/components/client.tsx b/packages/http-client-js/src/components/client.tsx new file mode 100644 index 00000000000..7fd951717a6 --- /dev/null +++ b/packages/http-client-js/src/components/client.tsx @@ -0,0 +1,137 @@ +import * as ay from "@alloy-js/core"; +import * as ts from "@alloy-js/typescript"; +import { ClassDeclaration } from "@alloy-js/typescript"; +import { $ } from "@typespec/compiler/experimental/typekit"; +import { ClassMethod } from "@typespec/emitter-framework/typescript"; +import * as cl from "@typespec/http-client"; +import { useClientLibrary } from "@typespec/http-client"; +import { flattenClients } from "../utils/client-discovery.js"; +import { buildClientParameters } from "../utils/parameters.jsx"; +import { getClientcontextDeclarationRef } from "./client-context/client-context-declaration.jsx"; +import { getClientContextFactoryRef } from "./client-context/client-context-factory.jsx"; +import { getOperationParameters } from "./operation-parameters.jsx"; + +export interface ClientProps {} + +export function Client(props: ClientProps) { + const namePolicy = ts.useTSNamePolicy(); + const { topLevel } = useClientLibrary(); + + return ay.mapJoin( + topLevel, + (client) => { + const fileName = namePolicy.getName($.client.getName(client), "variable"); + const flatClients = flattenClients(client); + return + {ay.mapJoin(flatClients, (client) => , { joiner: "\n\n" })} + ; + }, + { joiner: "\n\n" }, + ); +} + +export interface ClientClassProps { + client: cl.Client; +} + +export function getClientClassRef(client: cl.Client) { + return ay.refkey(client.type, "client-class"); +} + +function getClientContextFieldRef(client: cl.Client) { + return ay.refkey(client.type, "client-context"); +} +export function ClientClass(props: ClientClassProps) { + const namePolicy = ts.useTSNamePolicy(); + const clientName = namePolicy.getName($.client.getName(props.client), "class"); + const contextMemberRef = getClientContextFieldRef(props.client); + const contextDeclarationRef = getClientcontextDeclarationRef(props.client); + const clientClassRef = getClientClassRef(props.client); + const subClients = props.client.subClients; + const operations = props.client.operations; + return + ; + {ay.mapJoin(subClients, subClient => ( + + ), { joiner: "\n" })} + + {ay.mapJoin(operations, (op) => { + const parameters = getOperationParameters(op.httpOperation); + const args = (Object.keys(parameters)).map((p) => p); + + return + return ; + + })} + ; +} + +interface SubClientClassFieldProps { + client: cl.Client; +} + +function getSubClientClassFieldRef(client: cl.Client) { + return ay.refkey(client.type, "client-field"); +} + +function SubClientClassField(props: SubClientClassFieldProps) { + const parent = props.client.parent; + // If sub client has different parameters than client, don't add it as a subclass field + // Todo: We need to detect the extra parameters and make this field a factory for the subclient + if (parent && !$.client.haveSameConstructor(props.client, parent)) { + return null; + } + + const namePolicy = ts.useTSNamePolicy(); + const fieldName = namePolicy.getName($.client.getName(props.client), "class"); + const subClientClassRef = getClientClassRef(props.client); + const subClientFieldRef = getSubClientClassFieldRef(props.client); + return ; +} + +interface ClientConstructorProps { + client: cl.Client; +} + +function ClientConstructor(props: ClientConstructorProps) { + const subClients = props.client.subClients.filter((sc) => + $.client.haveSameConstructor(sc, props.client)); + const clientContextFieldRef = getClientContextFieldRef(props.client); + const clientContextFactoryRef = getClientContextFactoryRef(props.client); + const constructorParameters = buildClientParameters(props.client); + const args = Object.values(constructorParameters).map((p) => p.refkey); + + return + {clientContextFieldRef} = ; + {ay.mapJoin(subClients, subClient => { + const subClientFieldRef = getSubClientClassFieldRef(subClient); + const subClientArgs = calculateSubClientArgs(subClient, constructorParameters); + return <> + {subClientFieldRef} = ; + + }, {joiner: "\n"})} + ; +} + +function calculateSubClientArgs( + subClient: cl.Client, + parentParams: Record, +) { + const subClientParams = buildClientParameters(subClient); + return Object.entries(parentParams) + .filter(([name]) => Object.keys(subClientParams).includes(name)) + .map(([_, p]) => p.refkey); +} + +export interface NewClientExpressionProps { + client: cl.Client; + args: ay.Refkey[]; +} + +function NewClientExpression(props: NewClientExpressionProps) { + const clientConstructorRef = getClientClassRef(props.client); + + return <> + new + ; +} diff --git a/packages/http-client-js/src/components/encoding-provider.tsx b/packages/http-client-js/src/components/encoding-provider.tsx new file mode 100644 index 00000000000..4a56d8b4999 --- /dev/null +++ b/packages/http-client-js/src/components/encoding-provider.tsx @@ -0,0 +1,19 @@ +import { Children } from "@alloy-js/core"; +import { EncodingContext } from "../context/encoding/encoding-context.jsx"; +import { EncodingDefaults } from "../context/encoding/types.js"; +export interface EncodingProviderProps { + defaults?: EncodingDefaults; + children?: Children; +} + +export function EncodingProvider(props: EncodingProviderProps) { + const defaults: EncodingDefaults = { + bytes: "none", + datetime: "rfc3339", + ...props.defaults, + }; + + return + {props.children} + ; +} diff --git a/packages/http-client-js/src/components/external-packages/ts-http-runtime.ts b/packages/http-client-js/src/components/external-packages/ts-http-runtime.ts new file mode 100644 index 00000000000..bcf3dc219af --- /dev/null +++ b/packages/http-client-js/src/components/external-packages/ts-http-runtime.ts @@ -0,0 +1,22 @@ +import { createPackage } from "@alloy-js/typescript"; + +export const httpRuntimeTemplateLib = createPackage({ + name: "@typespec/ts-http-runtime", + version: "0.1.0", + descriptor: { + ".": { + named: [ + "Client", + "ClientOptions", + "getClient", + "KeyCredential", + "TokenCredential", + "isKeyCredential", + "PathUncheckedResponse", + "PipelineRequest", + "HttpResponse", + "RawHttpHeaders", + ], + }, + }, +}); diff --git a/packages/http-client-js/src/components/external-packages/uri-template.ts b/packages/http-client-js/src/components/external-packages/uri-template.ts new file mode 100644 index 00000000000..5100553c595 --- /dev/null +++ b/packages/http-client-js/src/components/external-packages/uri-template.ts @@ -0,0 +1,11 @@ +import { createPackage } from "@alloy-js/typescript"; + +export const uriTemplateLib = createPackage({ + name: "uri-template", + version: "^2.0.0", + descriptor: { + ".": { + named: ["parse"], + }, + }, +}); diff --git a/packages/http-client-js/src/components/http-request-options.tsx b/packages/http-client-js/src/components/http-request-options.tsx new file mode 100644 index 00000000000..351c5da619d --- /dev/null +++ b/packages/http-client-js/src/components/http-request-options.tsx @@ -0,0 +1,69 @@ +import { Children, code, Refkey } from "@alloy-js/core"; +import * as ts from "@alloy-js/typescript"; +import { ClientOperation } from "@typespec/http-client"; +import { EncodingProvider } from "./encoding-provider.jsx"; +import { HttpRequestParametersExpression } from "./http-request-parameters-expression.jsx"; +import { getOperationOptionsParameterRefkey } from "./operation-parameters.jsx"; +import { OperationTransformExpression } from "./transforms/operation-transform-expression.jsx"; + +export interface HttpRequestOptionsProps { + operation: ClientOperation; + refkey?: Refkey; + children?: Children; +} + +export function HttpRequestOptions(props: HttpRequestOptionsProps) { + return + + + + + ; +} + +export interface HttpRequestOptionsHeadersProps { + operation: ClientOperation; + children?: Children; +} + +HttpRequestOptions.Headers = function HttpRequestOptionsHeaders( + props: HttpRequestOptionsHeadersProps, +) { + // Extract the header request parameters from the operation + const httpOperation = props.operation.httpOperation; + const headers = httpOperation.parameters.properties.filter( + (p) => p.kind === "header" || p.kind === "contentType", + ); + + const optionsParam = getOperationOptionsParameterRefkey(props.operation.httpOperation); + return + , + ; +}; + +export interface HttpRequestOptionsBodyProps { + operation: ClientOperation; + itemName?: string; + children?: Children; +} + +HttpRequestOptions.Body = function HttpRequestOptionsBody(props: HttpRequestOptionsBodyProps) { + const httpOperation = props.operation.httpOperation; + const body = httpOperation.parameters.body; + + if (!body) { + return <>; + } + // The transformer to apply to the body. + const bodyTransform = <> + + ; + + return <> + , + ; +}; + +export function JSONSerializer(props: { children?: Children }) { + return code`JSON.stringify(${props.children})`; +} diff --git a/packages/http-client-js/src/components/http-request-parameters-expression.tsx b/packages/http-client-js/src/components/http-request-parameters-expression.tsx new file mode 100644 index 00000000000..815b34e0dbb --- /dev/null +++ b/packages/http-client-js/src/components/http-request-parameters-expression.tsx @@ -0,0 +1,70 @@ +import * as ay from "@alloy-js/core"; +import { Children, mapJoin } from "@alloy-js/core"; +import * as ts from "@alloy-js/typescript"; +import { ModelProperty } from "@typespec/compiler"; +import { useTransformNamePolicy } from "@typespec/emitter-framework"; +import { HttpProperty } from "@typespec/http"; +import { getDefaultValue } from "../utils/parameters.jsx"; +import { JsonTransform } from "./transforms/json/json-transform.jsx"; +export interface HttpRequestParametersExpressionProps { + optionsParameter: ay.Children; + parameters?: HttpProperty[]; + children?: Children; +} + +export function HttpRequestParametersExpression(props: HttpRequestParametersExpressionProps) { + const parameters: (ModelProperty | Children)[] = []; + const transformNamer = useTransformNamePolicy(); + + if (props.children || (Array.isArray(props.children) && props.children.length)) { + parameters.push(<> + {props.children}, + + ); + } + + if (!props.parameters && parameters.length) { + return + {parameters} + ; + } else if (!props.parameters) { + return ; + } + + const optionsParamRef = props.optionsParameter ?? "options"; + const members = mapJoin( + props.parameters, + (httpProperty) => { + const parameter = httpProperty.property; + + const defaultValue = getDefaultValue(httpProperty); + const paramItemRef: ay.Children = transformNamer.getApplicationName(parameter); + + const paramRef = ay.code`${optionsParamRef}?.${paramItemRef}`; + + if (defaultValue) { + const defaultAssignment = defaultValue ? ` ?? ${defaultValue}` : ""; + const headerValue = <>{paramRef}{defaultAssignment}; + const name = transformNamer.getTransportName(parameter); + const paramAssignment = ; + return paramAssignment; + } + + const itemRef: ay.Children = parameter.optional ? ay.code`${optionsParamRef}?` : null; + if (parameter.optional) { + return ay.code` + ...(${paramRef} && {${}}) + `; + } else { + return ; + } + }, + { joiner: ",\n" }, + ); + + parameters.push(...members); + + return + {parameters} + ; +} diff --git a/packages/http-client-js/src/components/http-request.tsx b/packages/http-client-js/src/components/http-request.tsx new file mode 100644 index 00000000000..b0401c78b52 --- /dev/null +++ b/packages/http-client-js/src/components/http-request.tsx @@ -0,0 +1,58 @@ +import { Children, code, refkey, Refkey } from "@alloy-js/core"; +import * as ts from "@alloy-js/typescript"; +import { Reference } from "@alloy-js/typescript"; +import { ClientOperation } from "@typespec/http-client"; +import { EncodingProvider } from "./encoding-provider.jsx"; +import { uriTemplateLib } from "./external-packages/uri-template.js"; +import { HttpRequestOptions } from "./http-request-options.js"; +import { HttpRequestParametersExpression } from "./http-request-parameters-expression.js"; +import { getOperationOptionsParameterRefkey } from "./operation-parameters.jsx"; + +export interface HttpRequestProps { + operation: ClientOperation; + responseRefkey?: Refkey; +} + +export function HttpRequest(props: HttpRequestProps) { + const operationUrlRefkey = refkey(); + const requestOptionsRefkey = refkey(); + const httpResponseRefkey = props.responseRefkey ?? refkey(); + const verb = props.operation.httpOperation.verb; + return <> + + + + + + {code` + await client.pathUnchecked(${}).${verb}(${}) + + if (typeof options?.operationOptions?.onResponse === "function") { + options?.operationOptions?.onResponse(response); + } + + `} + + + ; +} + +export interface HttpUrlProps { + operation: ClientOperation; + refkey?: Refkey; + children?: Children; +} + +HttpRequest.Url = function HttpUrlDeclaration(props: HttpUrlProps) { + const httpOperation = props.operation.httpOperation; + const urlTemplate = httpOperation.uriTemplate; + const urlParameters = httpOperation.parameters.properties.filter( + (p) => p.kind === "path" || p.kind === "query", + ); + const optionsParameter = getOperationOptionsParameterRefkey(props.operation.httpOperation); + return + + {uriTemplateLib.parse}({JSON.stringify(urlTemplate)}).expand({}) + + ; +}; diff --git a/packages/http-client-js/src/components/http-response.tsx b/packages/http-client-js/src/components/http-response.tsx new file mode 100644 index 00000000000..1176d72584b --- /dev/null +++ b/packages/http-client-js/src/components/http-response.tsx @@ -0,0 +1,68 @@ +import { Children, code, mapJoin, Refkey } from "@alloy-js/core"; +import { isVoidType } from "@typespec/compiler"; +import { $ } from "@typespec/compiler/experimental/typekit"; +import { ClientOperation } from "@typespec/http-client"; +import { getCreateRestErrorRefkey } from "./static-helpers/rest-error.jsx"; +import { ContentTypeEncodingProvider } from "./transforms/content-type-encoding-provider.jsx"; +import { JsonTransform } from "./transforms/json/json-transform.jsx"; +export interface HttpResponseProps { + operation: ClientOperation; + responseRefkey: Refkey; + children?: Children; +} + +export function HttpResponse(props: HttpResponseProps) { + return <> + + + {code`throw ${getCreateRestErrorRefkey()}(response);`} + ; +} + +export interface HttpResponsesProps { + operation: ClientOperation; + children?: Children; +} + +export function HttpResponses(props: HttpResponsesProps) { + // Handle response by status code and content type + const responses = $.httpOperation.flattenResponses(props.operation.httpOperation); + return mapJoin( + responses.filter((r) => !$.httpResponse.isErrorResponse(r)), + ({ statusCode, contentType, responseContent, type }) => { + const body = responseContent.body; + + let expression = code`return;`; + + const contentTypeCheck = body + ? ` && response.headers["content-type"]?.includes("${contentType}")` + : " && !response.body"; + + if (body && (body.bodyKind === "single" || (type && !isVoidType(type)))) { + expression = + + return !; + ; + } + + if ($.httpResponse.statusCode.isSingle(statusCode)) { + return code` + if (+response.status === ${statusCode}${contentTypeCheck}) { + ${expression} + } + `; + } + + if ($.httpResponse.statusCode.isRange(statusCode)) { + return code` + if (+response.status >= ${statusCode.start} && +response.status <= ${statusCode.end} ${contentTypeCheck}) { + ${expression} + } + `; + } + + return null; + }, + { joiner: "\n\n" }, + ); +} diff --git a/packages/http-client-js/src/components/models.tsx b/packages/http-client-js/src/components/models.tsx new file mode 100644 index 00000000000..f727e2cf47e --- /dev/null +++ b/packages/http-client-js/src/components/models.tsx @@ -0,0 +1,29 @@ +import { mapJoin, refkey } from "@alloy-js/core"; +import * as ts from "@alloy-js/typescript"; +import { $ } from "@typespec/compiler/experimental/typekit"; +import * as ef from "@typespec/emitter-framework/typescript"; +import { useClientLibrary } from "@typespec/http-client"; +import { getFileTypeReference } from "./static-helpers/multipart-helpers.jsx"; + +export interface ModelsProps { + path?: string; +} + +export function Models(props: ModelsProps) { + const clientLibrary = useClientLibrary(); + const dataTypes = clientLibrary.dataTypes; + return + {mapJoin( + dataTypes, + (type) => { + if($.model.is(type) && $.model.isHttpFile(type)) { + return + {getFileTypeReference()} + + } + return $.array.is(type) || $.record.is(type) ? null : + }, + { joiner: "\n\n" } + )} + ; +} diff --git a/packages/http-client-js/src/components/operation-options.tsx b/packages/http-client-js/src/components/operation-options.tsx new file mode 100644 index 00000000000..3136f3dc890 --- /dev/null +++ b/packages/http-client-js/src/components/operation-options.tsx @@ -0,0 +1,26 @@ +import * as ay from "@alloy-js/core"; +import * as ts from "@alloy-js/typescript"; +import * as ef from "@typespec/emitter-framework/typescript"; +import { HttpOperation } from "@typespec/http"; +import { hasDefaultValue } from "../utils/parameters.jsx"; +import { getOperationOptionsInterfaceRefkey } from "./static-helpers/interfaces.jsx"; +export interface OperationOptionsProps { + operation: HttpOperation; +} + +export function getOperationOptionsTypeRefkey(operation: HttpOperation) { + return ay.refkey(operation, "operation-options"); +} +export function OperationOptionsDeclaration(props: OperationOptionsProps) { + const namePolicy = ts.useTSNamePolicy(); + const interfaceName = namePolicy.getName(props.operation.operation.name + "Options", "interface"); + const optionalParameters = props.operation.parameters.properties.filter( + (p) => p.property.optional || hasDefaultValue(p), + ); + + return + {ay.mapJoin(optionalParameters, (parameter) => ( + } /> + ))} + ; +} diff --git a/packages/http-client-js/src/components/operation-parameters.tsx b/packages/http-client-js/src/components/operation-parameters.tsx new file mode 100644 index 00000000000..ce6ebfe580c --- /dev/null +++ b/packages/http-client-js/src/components/operation-parameters.tsx @@ -0,0 +1,43 @@ +import * as ay from "@alloy-js/core"; +import * as ts from "@alloy-js/typescript"; +import { useTransformNamePolicy } from "@typespec/emitter-framework"; +import * as ef from "@typespec/emitter-framework/typescript"; +import { HttpOperation } from "@typespec/http"; +import { hasDefaultValue } from "../utils/parameters.jsx"; +import { getOperationOptionsTypeRefkey } from "./operation-options.jsx"; + +export interface OperationParametersProps { + operation: HttpOperation; +} + +export function getOperationOptionsParameterRefkey(operation: HttpOperation) { + return ay.refkey(operation, "operation-options-parameter"); +} + +export function getOperationParameters( + operation: HttpOperation, +): Record { + const transformNamer = useTransformNamePolicy(); + const requiredParameters = operation.parameters.properties + .filter((p) => !p.property.optional && !hasDefaultValue(p)) + .filter((p) => p.path.length === 1); + + const parameters: Record = {}; + + for (const parameter of requiredParameters) { + const parameterDescriptor: ts.ParameterDescriptor = { + refkey: ay.refkey(parameter.property, "operation-parameter"), + type: , + }; + const name = transformNamer.getApplicationName(parameter.property); + parameters[name] = parameterDescriptor; + } + + parameters["options"] = { + refkey: getOperationOptionsParameterRefkey(operation), + type: getOperationOptionsTypeRefkey(operation), + optional: true, + }; + + return parameters; +} diff --git a/packages/http-client-js/src/components/output.tsx b/packages/http-client-js/src/components/output.tsx new file mode 100644 index 00000000000..c938aaaace3 --- /dev/null +++ b/packages/http-client-js/src/components/output.tsx @@ -0,0 +1,27 @@ +import * as ay from "@alloy-js/core"; +import * as ts from "@alloy-js/typescript"; +import { TransformNamePolicyContext } from "@typespec/emitter-framework"; +import { ClientLibrary } from "@typespec/http-client/components"; +import { httpParamsMutator } from "../utils/operations.js"; +import { EncodingProvider } from "./encoding-provider.jsx"; +import { httpRuntimeTemplateLib } from "./external-packages/ts-http-runtime.js"; +import { uriTemplateLib } from "./external-packages/uri-template.js"; +import { createTransformNamePolicy } from "./transforms/transform-name-policy.js"; + +export interface OutputProps { + children?: ay.Children; +} + +export function Output(props: OutputProps) { + const tsNamePolicy = ts.createTSNamePolicy(); + const defaultTransformNamePolicy = createTransformNamePolicy(); + return + + + + {props.children} + + + + ; +} diff --git a/packages/http-client-js/src/components/serializers.tsx b/packages/http-client-js/src/components/serializers.tsx new file mode 100644 index 00000000000..2efb8f6a5f0 --- /dev/null +++ b/packages/http-client-js/src/components/serializers.tsx @@ -0,0 +1,47 @@ +import * as ts from "@alloy-js/typescript"; +import { + DateDeserializer, + DateRfc3339Serializer, + DateRfc7231Deserializer, + DateRfc7231Serializer, + DateUnixTimestampDeserializer, + DateUnixTimestampSerializer, +} from "@typespec/emitter-framework/typescript"; +import { useClientLibrary } from "@typespec/http-client"; +import { flattenClients } from "../utils/client-discovery.js"; +import { EncodingProvider } from "./encoding-provider.jsx"; +import { DecodeBase64, EncodeUint8Array } from "./static-helpers/bytes-encoding.jsx"; +import { JsonTransformDeclaration } from "./transforms/json/json-transform.jsx"; +import { TransformDeclaration } from "./transforms/operation-transform-declaration.jsx"; +export interface ModelSerializersProps { + path?: string; +} + +export function ModelSerializers(props: ModelSerializersProps) { + const clientLibrary = useClientLibrary(); + const dataTypes = clientLibrary.dataTypes; + const flatClients = clientLibrary.topLevel.flatMap((c) => flattenClients(c)); + const operations = flatClients.flatMap((c) => c.operations); + // Todo: Handle other kinds of serialization, for example XML. Might need to + // revisit the way we process these and might need to track the relationship + // between the data type and the operations that consume them. + return + + + + + + + + + {operations.map(o => )} + {dataTypes + .filter((m) => m.kind === "Model" || m.kind === "Union") + .map((type) => ( + + + + + ))} + ; +} diff --git a/packages/http-client-js/src/components/static-helpers/bytes-encoding.tsx b/packages/http-client-js/src/components/static-helpers/bytes-encoding.tsx new file mode 100644 index 00000000000..148d767e4f9 --- /dev/null +++ b/packages/http-client-js/src/components/static-helpers/bytes-encoding.tsx @@ -0,0 +1,41 @@ +import * as ay from "@alloy-js/core"; +import * as ts from "@alloy-js/typescript"; + +export function getEncodeUint8ArrayRef(): ay.Refkey { + return ay.refkey("encodeUint8Array"); +} + +export function EncodeUint8Array(): string { + const valueRef = ay.refkey(); + const encodingRef = ay.refkey(); + const refkey = getEncodeUint8ArrayRef(); + return + {ay.code` + if (!${valueRef}) { + return ${valueRef} as any; + } + return Buffer.from(${valueRef}).toString(${encodingRef}); + `} + ; +} + +export function getDecodeUint8ArrayRef(): ay.Refkey { + return ay.refkey("decodeUint8Array"); +} +export function DecodeBase64(): string { + const refkey = getDecodeUint8ArrayRef(); + const valueRef = ay.refkey(); + return + {ay.code` + if(!${valueRef}) { + return ${valueRef} as any; + } + // Normalize Base64URL to Base64 + const base64 = ${valueRef}.replace(/-/g, '+').replace(/_/g, '/') + .padEnd(${valueRef}.length + (4 - (${valueRef}.length % 4)) % 4, '='); + + return new Uint8Array(Buffer.from(base64, 'base64')); + `} + + ; +} diff --git a/packages/http-client-js/src/components/static-helpers/interfaces.tsx b/packages/http-client-js/src/components/static-helpers/interfaces.tsx new file mode 100644 index 00000000000..d6871492af4 --- /dev/null +++ b/packages/http-client-js/src/components/static-helpers/interfaces.tsx @@ -0,0 +1,26 @@ +import * as ay from "@alloy-js/core"; +import * as ts from "@alloy-js/typescript"; +import { httpRuntimeTemplateLib } from "../external-packages/ts-http-runtime.js"; + +export function getOperationOptionsInterfaceRefkey() { + return ay.refkey("OperationOptions", "interface"); +} + +export function OperationOptionsInterfaceDeclaration() { + const declarationRefkey = getOperationOptionsInterfaceRefkey(); + const onResponseCallback = ay.code`(rawResponse: ${httpRuntimeTemplateLib.PathUncheckedResponse}) => void`; + + const operationOptions = + + +; + return + + ; +} + +export function Interfaces() { + return + + ; +} diff --git a/packages/http-client-js/src/components/static-helpers/multipart-helpers.tsx b/packages/http-client-js/src/components/static-helpers/multipart-helpers.tsx new file mode 100644 index 00000000000..ced7751409e --- /dev/null +++ b/packages/http-client-js/src/components/static-helpers/multipart-helpers.tsx @@ -0,0 +1,59 @@ +import * as ay from "@alloy-js/core"; +import * as ts from "@alloy-js/typescript"; +export interface MultipartHelpersProps {} + +export function getFileContentsTypeReference() { + return ay.refkey("FileContents", "type", "static-helpers"); +} + +export function getCreateFilePartDescriptorReference() { + return ay.refkey("createFilePartDescriptor", "function", "static-helpers"); +} + +export function getFileTypeReference() { + return ay.refkey("File", "type", "static-helpers"); +} + +export function MultipartHelpers(props: MultipartHelpersProps) { + return + + contents: {getFileContentsTypeReference()}; + contentType?: string; + filename?: string; + + + {ay.mapJoin(["string", "NodeJS.ReadableStream", "ReadableStream", "Uint8Array", "Blob"], (t) => t, {joiner: " | "})} + + + {ay.code` + if (fileInput.contents) { + return { + name: partName, + body: fileInput.contents, + contentType: fileInput.contentType ?? defaultContentType, + filename: fileInput.filename, + }; + } else { + return { + name: partName, + body: fileInput, + contentType: defaultContentType, + }; + } + `} + + ; +} + +function getCreateFilePartParameters() { + return { + partName: "string", + fileInput: "any", + defaultContentType: { + optional: true, + key: "defaultContentType", + refkey: ay.refkey(), + type: "string", + }, + }; +} diff --git a/packages/http-client-js/src/components/static-helpers/rest-error.tsx b/packages/http-client-js/src/components/static-helpers/rest-error.tsx new file mode 100644 index 00000000000..34b9dfba02c --- /dev/null +++ b/packages/http-client-js/src/components/static-helpers/rest-error.tsx @@ -0,0 +1,61 @@ +import * as ay from "@alloy-js/core"; +import * as ts from "@alloy-js/typescript"; +import { httpRuntimeTemplateLib } from "../external-packages/ts-http-runtime.js"; + +export function getRestErrorRefkey() { + return ay.refkey("rest-error", "static-helpers", "class"); +} + +export function getCreateRestErrorRefkey() { + return ay.refkey("create-rest-error", "static-helpers", "function"); +} + +export function RestError() { + const requestRefkey = ay.refkey("request", "rest-error-class-field"); + const responseRefkey = ay.refkey("response", "rest-error-class-field"); + const statusRefkey = ay.refkey("status", "rest-error-class-field"); + const bodyRefkey = ay.refkey("body", "rest-error-class-field"); + const headersRefkey = ay.refkey("headers", "rest-error-class-field"); + const constructorRefkey = ay.refkey("constructor", "rest-error-class-method"); + const fromHttpResponseRefkey = ay.refkey("from-http-response", "rest-error-class-method"); + + const restErrorClass = + + + + + + + + + {ay.code` + // Create an error message that includes relevant details. + super(\`$\{message\} - HTTP $\{response.status} received for $\{response.request.method} $\{response.request.url}\`); + this.name = 'RestError'; + this.request = response.request; + this.response = response; + this.status = response.status; + this.headers = response.headers; + this.body = response.body; + + // Set the prototype explicitly. + Object.setPrototypeOf(this, RestError.prototype); + `} + + + {ay.code` + const defaultMessage = \`Unexpected HTTP status code: $\{response.status}\`; + return new RestError(defaultMessage, response); + `} + + ; + + const createRestError = + + {ay.code` + return ${fromHttpResponseRefkey}(response); + `} + ; + + return [restErrorClass, "\n\n", createRestError]; +} diff --git a/packages/http-client-js/src/components/testing/client-options.tsx b/packages/http-client-js/src/components/testing/client-options.tsx new file mode 100644 index 00000000000..1a54067025b --- /dev/null +++ b/packages/http-client-js/src/components/testing/client-options.tsx @@ -0,0 +1,32 @@ +import * as ay from "@alloy-js/core"; +import { config } from "dotenv"; + +config(); + +export interface ClientTestOptions {} + +export function ClientTestOptions(_props: ClientTestOptions) { + if (process.env.TYPESPEC_JS_EMITTER_TESTING !== "true") { + return null; + } + + return ay.code` + allowInsecureConnection: true, + retryOptions: { + maxRetries: 1, + }`; +} + +export function addClientTestOptions( + options: ay.Children[], + settings: { location?: "front" | "back" } = { location: "front" }, +) { + if (process.env.TYPESPEC_JS_EMITTER_TESTING === "true") { + const testOptions = ; + if (settings.location === "front") { + options.unshift(testOptions); + } else { + options.push(testOptions); + } + } +} diff --git a/packages/http-client-js/src/components/transforms/content-type-encoding-provider.tsx b/packages/http-client-js/src/components/transforms/content-type-encoding-provider.tsx new file mode 100644 index 00000000000..b66419be0d4 --- /dev/null +++ b/packages/http-client-js/src/components/transforms/content-type-encoding-provider.tsx @@ -0,0 +1,28 @@ +import { Children } from "@alloy-js/core"; +import { EncodingDefaults } from "../../context/encoding/types.js"; +import { EncodingProvider } from "../encoding-provider.jsx"; + +export interface ContentTypeEncodingProviderProps { + contentType?: string; + children?: Children; +} + +export function ContentTypeEncodingProvider(props: ContentTypeEncodingProviderProps) { + const contentType = props.contentType ?? "application/json"; + let defaults: EncodingDefaults = { + bytes: "none", + datetime: "rfc3339", + }; + + // Follows the media type conventions from RFC 6838 section 4.2.8, which standardizes the +json suffix for JSON-structured syntaxes. + if (contentType?.includes("application/json") || contentType.trim()?.endsWith("+json")) { + defaults = { + bytes: "base64", + datetime: "rfc3339", + }; + } + + return + {props.children} + ; +} diff --git a/packages/http-client-js/src/components/transforms/data-transform.tsx b/packages/http-client-js/src/components/transforms/data-transform.tsx new file mode 100644 index 00000000000..ea3788277e5 --- /dev/null +++ b/packages/http-client-js/src/components/transforms/data-transform.tsx @@ -0,0 +1,39 @@ +import { Children, Refkey } from "@alloy-js/core"; +import { EncodeData, ModelProperty, Scalar } from "@typespec/compiler"; +import { $ } from "@typespec/compiler/experimental/typekit"; +import { reportDiagnostic } from "../../lib.js"; +import { unpackProperty } from "../utils/unpack-model-property.js"; +import { getScalarTransformer } from "./scalar-transform.jsx"; + +export interface ScalarDataTransformProps { + itemRef: Refkey | Children; + type: Scalar | ModelProperty; + target: "transport" | "application"; +} + +export function ScalarDataTransform(props: ScalarDataTransformProps) { + let scalar: Scalar; + let encoding: EncodeData | undefined; + if ($.modelProperty.is(props.type)) { + const valueType = unpackProperty(props.type); + if (!$.scalar.is(valueType)) { + reportDiagnostic($.program, { + code: "unexpected-non-scalar-type", + target: props.type, + }); + return null; + } + scalar = valueType; + encoding = $.modelProperty.getEncoding(props.type); + } else { + scalar = props.type; + encoding = $.scalar.getEncoding(scalar); + } + // const encoding = $.scalar.getEncoding(scalar); + + const { toApplication, toTransport } = getScalarTransformer(scalar); + + return props.target === "transport" + ? toTransport(props.itemRef, encoding) + : toApplication(props.itemRef, encoding); +} diff --git a/packages/http-client-js/src/components/transforms/json/json-array-transform.tsx b/packages/http-client-js/src/components/transforms/json/json-array-transform.tsx new file mode 100644 index 00000000000..b46362197c2 --- /dev/null +++ b/packages/http-client-js/src/components/transforms/json/json-array-transform.tsx @@ -0,0 +1,83 @@ +import * as ay from "@alloy-js/core"; +import * as ts from "@alloy-js/typescript"; +import * as ef from "@typespec/emitter-framework/typescript"; + +import { Model } from "@typespec/compiler"; +import { $ } from "@typespec/compiler/experimental/typekit"; +import { JsonTransform } from "./json-transform.jsx"; + +export interface JsonArrayTransformProps { + itemRef: ay.Refkey | ay.Children; + type: Model; + target: "transport" | "application"; +} + +export function JsonArrayTransform(props: JsonArrayTransformProps) { + if (!$.array.is(props.type)) { + return null; + } + + const elementType = $.array.getElementType(props.type); + + return ay.code` + if(!${props.itemRef}) { + return ${props.itemRef} as any; + } + const _transformedArray = []; + + for (const item of ${props.itemRef} ?? []) { + const transformedItem = ${}; + _transformedArray.push(transformedItem); + } + + return _transformedArray as any; + `; +} + +export function getJsonArrayTransformRefkey( + type: Model, + target: "transport" | "application", +): ay.Refkey { + return ay.refkey(type, "json_array_transform", target); +} + +export interface JsonArrayTransformDeclarationProps { + type: Model; + target: "transport" | "application"; +} + +export function JsonArrayTransformDeclaration(props: JsonArrayTransformDeclarationProps) { + if (!$.array.is(props.type)) { + return null; + } + + const elementType = $.array.getElementType(props.type); + const elementName = + "name" in elementType && typeof elementType.name === "string" ? elementType.name : "element"; + + const namePolicy = ts.useTSNamePolicy(); + const transformName = namePolicy.getName( + `json_Array_${elementName}_to_${props.target}_transform`, + "function", + ); + + const itemType = ay.code`Array<${}>`; + const returnType = props.target === "transport" ? "any" : itemType; + const inputType = props.target === "transport" ? <>{itemType} | null : "any"; + const inputRef = ay.refkey(); + + const parameters: Record = { + items_: { type: inputType, refkey: inputRef, optional: true }, + }; + + const declarationRefkey = getJsonArrayTransformRefkey(props.type, props.target); + return + + ; +} diff --git a/packages/http-client-js/src/components/transforms/json/json-model-additional-properties-transform.tsx b/packages/http-client-js/src/components/transforms/json/json-model-additional-properties-transform.tsx new file mode 100644 index 00000000000..97f1eeae590 --- /dev/null +++ b/packages/http-client-js/src/components/transforms/json/json-model-additional-properties-transform.tsx @@ -0,0 +1,47 @@ +import * as ay from "@alloy-js/core"; +import * as ts from "@alloy-js/typescript"; +import { Model } from "@typespec/compiler"; +import { $ } from "@typespec/compiler/experimental/typekit"; +import { getJsonRecordTransformRefkey } from "./json-record-transform.jsx"; + +export interface JsonAdditionalPropertiesTransformProps { + itemRef: ay.Children; + type: Model; + target: "transport" | "application"; +} + +export function JsonAdditionalPropertiesTransform(props: JsonAdditionalPropertiesTransformProps) { + const additionalProperties = $.model.getAdditionalPropertiesRecord(props.type); + + if (!additionalProperties) { + return null; + } + + if (props.target === "application") { + const properties = $.model.getProperties(props.type, { includeExtended: true }); + const destructuredProperties = ay.mapJoin(properties, (name) => name, { + joiner: ",", + ender: ",", + }); + + // Inline destructuring that extracts the properties and passes the rest to jsonRecordUnknownToApplicationTransform_2 + const inlineDestructure = ay.code` + ${getJsonRecordTransformRefkey(additionalProperties, props.target)}( + (({ ${destructuredProperties} ...rest }) => rest)(${props.itemRef}) + ), + `; + + return <> + + {inlineDestructure} + + ; + } + + const itemRef = ay.code`${props.itemRef}.additionalProperties`; + + return <> + ...({getJsonRecordTransformRefkey(additionalProperties, props.target)}({itemRef}) + ), + ; +} diff --git a/packages/http-client-js/src/components/transforms/json/json-model-base-transform.tsx b/packages/http-client-js/src/components/transforms/json/json-model-base-transform.tsx new file mode 100644 index 00000000000..a910052a10a --- /dev/null +++ b/packages/http-client-js/src/components/transforms/json/json-model-base-transform.tsx @@ -0,0 +1,19 @@ +import * as ay from "@alloy-js/core"; +import { Model } from "@typespec/compiler"; +import { JsonTransform } from "./json-transform.jsx"; + +export interface JsonModelBaseTransformProps { + itemRef: ay.Children; + type: Model; + target: "transport" | "application"; +} + +export function JsonModelBaseTransform(props: JsonModelBaseTransformProps) { + const baseModel = props.type.baseModel; + + if (!baseModel) { + return null; + } + + return <>...,; +} diff --git a/packages/http-client-js/src/components/transforms/json/json-model-property-transform.tsx b/packages/http-client-js/src/components/transforms/json/json-model-property-transform.tsx new file mode 100644 index 00000000000..4381870bf5f --- /dev/null +++ b/packages/http-client-js/src/components/transforms/json/json-model-property-transform.tsx @@ -0,0 +1,41 @@ +import * as ay from "@alloy-js/core"; +import * as ts from "@alloy-js/typescript"; +import { ModelProperty } from "@typespec/compiler"; +import { $ } from "@typespec/compiler/experimental/typekit"; +import { unpackProperty } from "../../utils/unpack-model-property.js"; +import { ScalarDataTransform } from "../data-transform.jsx"; +import { useTransformNamePolicy } from "../transform-name-policy.js"; +import { JsonTransform } from "./json-transform.jsx"; + +export interface JsonModelPropertyTransformProps { + itemRef: ay.Refkey | ay.Children; + type: ModelProperty; + target: "transport" | "application"; +} + +export function JsonModelPropertyTransform(props: JsonModelPropertyTransformProps): ay.Component { + const transformNamer = useTransformNamePolicy(); + const propertyValueType = unpackProperty(props.type); + + const transportName = transformNamer.getTransportName(props.type); + const applicationName = transformNamer.getApplicationName(props.type); + const targetName = props.target === "transport" ? transportName : applicationName; + const sourceName = props.target === "transport" ? applicationName : transportName; + + const propertyValueRef = props.itemRef ? ay.code`${props.itemRef}.${sourceName}` : sourceName; + let propertyValue: ay.Children; + + if ($.scalar.is(propertyValueType)) { + propertyValue = + ; + } else { + propertyValue = + ; + } + + return ; +} diff --git a/packages/http-client-js/src/components/transforms/json/json-model-transform.tsx b/packages/http-client-js/src/components/transforms/json/json-model-transform.tsx new file mode 100644 index 00000000000..22c94eba123 --- /dev/null +++ b/packages/http-client-js/src/components/transforms/json/json-model-transform.tsx @@ -0,0 +1,87 @@ +import * as ay from "@alloy-js/core"; +import * as ts from "@alloy-js/typescript"; + +import { Model } from "@typespec/compiler"; +import { $ } from "@typespec/compiler/experimental/typekit"; +import { JsonAdditionalPropertiesTransform } from "./json-model-additional-properties-transform.jsx"; +import { JsonModelPropertyTransform } from "./json-model-property-transform.jsx"; +import { JsonRecordTransformDeclaration } from "./json-record-transform.jsx"; +import { + getJsonTransformDiscriminatorRefkey, + JsonTransformDiscriminatorDeclaration, +} from "./json-transform-discriminator.jsx"; + +export interface JsonModelTransformProps { + itemRef: ay.Refkey | ay.Children; + type: Model; + target: "transport" | "application"; +} + +export function JsonModelTransform(props: JsonModelTransformProps) { + // Need to skip never properties + const properties = Array.from( + $.model.getProperties(props.type, { includeExtended: true }).values(), + ).filter((p) => !$.type.isNever(p.type)); + + const discriminator = $.type.getDiscriminator(props.type); + + const discriminate = getJsonTransformDiscriminatorRefkey(props.type, props.target); + + return + + {discriminator ? <>...{discriminate}({props.itemRef}),: null} + {ay.mapJoin(properties, (property) => { + return ; + }, {joiner: ",\n"})} + ; +} + +export function getJsonModelTransformRefkey( + type: Model, + target: "transport" | "application", +): ay.Refkey { + return ay.refkey(type, "json_model_transform", target); +} + +export interface JsonModelTransformDeclarationProps { + type: Model; + target: "transport" | "application"; +} + +export function JsonModelTransformDeclaration( + props: JsonModelTransformDeclarationProps, +): ay.Children { + const namePolicy = ts.useTSNamePolicy(); + const transformName = namePolicy.getName( + `json_${props.type.name}_to_${props.target}_transform`, + "function", + ); + + const returnType = props.target === "transport" ? "any" : ay.refkey(props.type); + const inputType = props.target === "transport" ? <>{ay.refkey(props.type)} | null : "any"; + const inputRef = ay.refkey(); + + const parameters: Record = { + // Make the input optional to make the transform more robust and check against null and undefined + input_: { type: inputType, refkey: inputRef, optional: true }, + }; + + const spread = $.model.getSpreadType(props.type); + const hasAdditionalProperties = spread && $.model.is(spread) && $.record.is(spread); + + const declarationRefkey = getJsonModelTransformRefkey(props.type, props.target); + return <> + + {hasAdditionalProperties ? : null} + + + {ay.code` + if(!${inputRef}) { + return ${inputRef} as any; + } + + `} + return !; + + ; +} diff --git a/packages/http-client-js/src/components/transforms/json/json-record-transform.tsx b/packages/http-client-js/src/components/transforms/json/json-record-transform.tsx new file mode 100644 index 00000000000..30ebc81ca05 --- /dev/null +++ b/packages/http-client-js/src/components/transforms/json/json-record-transform.tsx @@ -0,0 +1,84 @@ +import * as ay from "@alloy-js/core"; +import * as ts from "@alloy-js/typescript"; + +import { Model } from "@typespec/compiler"; +import { $ } from "@typespec/compiler/experimental/typekit"; +import { JsonTransform } from "./json-transform.jsx"; + +export interface JsonRecordTransformProps { + itemRef: ay.Refkey | ay.Children; + type: Model; + target: "transport" | "application"; +} + +export function JsonRecordTransform(props: JsonRecordTransformProps) { + if (!$.record.is(props.type)) { + return null; + } + + const elementType = $.record.getElementType(props.type); + + // TODO: Do we need to cast? + return ay.code` + if(!${props.itemRef}) { + return ${props.itemRef} as any; + } + + const _transformedRecord: any = {}; + + for (const [key, value] of Object.entries(${props.itemRef} ?? {})) { + const transformedItem = ${}; + _transformedRecord[key] = transformedItem; + } + + return _transformedRecord; + `; +} + +export function getJsonRecordTransformRefkey( + type: Model, + target: "transport" | "application", +): ay.Refkey { + return ay.refkey(type, "json_record_transform", target); +} + +export interface JsonRecordTransformDeclarationProps { + type: Model; + target: "transport" | "application"; +} + +export function JsonRecordTransformDeclaration(props: JsonRecordTransformDeclarationProps) { + if (!$.record.is(props.type)) { + return null; + } + + const elementType = $.record.getElementType(props.type); + const elementName = + "name" in elementType && typeof elementType.name === "string" ? elementType.name : "element"; + + const namePolicy = ts.useTSNamePolicy(); + const transformName = namePolicy.getName( + `json_Record_${elementName}_to_${props.target}_transform`, + "function", + ); + + const itemType = ay.code`Record`; + const returnType = props.target === "transport" ? "any" : itemType; + const inputType = props.target === "transport" ? <>{itemType} | null : "any"; + const inputRef = ay.refkey(); + + const parameters: Record = { + items_: { type: inputType, refkey: inputRef, optional: true }, + }; + + const declarationRefkey = getJsonRecordTransformRefkey(props.type, props.target); + return + + ; +} diff --git a/packages/http-client-js/src/components/transforms/json/json-transform-discriminator.tsx b/packages/http-client-js/src/components/transforms/json/json-transform-discriminator.tsx new file mode 100644 index 00000000000..b32d8d02b7f --- /dev/null +++ b/packages/http-client-js/src/components/transforms/json/json-transform-discriminator.tsx @@ -0,0 +1,114 @@ +import * as ay from "@alloy-js/core"; +import * as ts from "@alloy-js/typescript"; +import { + DiscriminatedUnion, + DiscriminatedUnionLegacy, + Discriminator, + getDiscriminatedUnion, + ignoreDiagnostics, + Model, + Union, +} from "@typespec/compiler"; +import { $ } from "@typespec/compiler/experimental/typekit"; +import { JsonTransform } from "./json-transform.jsx"; + +export interface JsonTransformDiscriminatorProps { + itemRef: ay.Refkey | ay.Children; + discriminator: Discriminator; + type: Union | Model; + target: "application" | "transport"; +} + +export function JsonTransformDiscriminator(props: JsonTransformDiscriminatorProps) { + let discriminatedUnion: DiscriminatedUnion | DiscriminatedUnionLegacy | undefined = $.union.is( + props.type, + ) + ? $.type.getDiscriminatedUnion(props.type) + : undefined; + + let propertyName: string | undefined = discriminatedUnion?.options.discriminatorPropertyName; + if (!discriminatedUnion && props.discriminator) { + discriminatedUnion = ignoreDiagnostics(getDiscriminatedUnion(props.type, props.discriminator)); + propertyName = props.discriminator.propertyName; + } + + if (!discriminatedUnion || !propertyName) { + return ay.code`${props.itemRef}`; + } + + const discriminatorRef = ay.code`${props.itemRef}.${props.discriminator.propertyName}`; + + // Need to cast to make sure that the general types which usually have a broader + // type in the discriminator are compatible. + const itemRef = ay.code`${props.itemRef} as any`; + const discriminatingCases = ay.mapJoin( + discriminatedUnion.variants, + (name, variant) => { + return ay.code` + if( discriminatorValue === ${JSON.stringify(name)}) { + return ${}! + } + `; + }, + { joiner: "\n\n" }, + ); + + return <> + const discriminatorValue = {discriminatorRef}; + {discriminatingCases} + <> + console.warn(`Received unknown kind: ` + discriminatorValue); + return {itemRef} + ; +} + +export function getJsonTransformDiscriminatorRefkey( + type: Union | Model, + target: "application" | "transport", +) { + return ay.refkey(type, "json_transform_discriminator_", target); +} + +export interface JsonTransformDiscriminatorDeclarationProps { + type: Union | Model; + target: "application" | "transport"; +} + +export function JsonTransformDiscriminatorDeclaration( + props: JsonTransformDiscriminatorDeclarationProps, +) { + const discriminator = $.type.getDiscriminator(props.type); + if (!discriminator) { + return null; + } + + const namePolicy = ay.useNamePolicy(); + const transformName = namePolicy.getName( + `json_${props.type.name}_to_${props.target}_discriminator`, + "function", + ); + + const typeRef = ay.refkey(props.type); + const returnType = props.target === "transport" ? "any" : typeRef; + const inputType = props.target === "transport" ? typeRef : "any"; + const inputRef = ay.refkey(); + + const parameters: Record = { + input_: { type: inputType, refkey: inputRef, optional: true }, + }; + + return + {ay.code` + if(!${inputRef}) { + return ${inputRef} as any; + } + `} + + ; +} diff --git a/packages/http-client-js/src/components/transforms/json/json-transform.tsx b/packages/http-client-js/src/components/transforms/json/json-transform.tsx new file mode 100644 index 00000000000..4c16b810476 --- /dev/null +++ b/packages/http-client-js/src/components/transforms/json/json-transform.tsx @@ -0,0 +1,113 @@ +import * as ay from "@alloy-js/core"; +import { Type } from "@typespec/compiler"; +import { $ } from "@typespec/compiler/experimental/typekit"; +import { ScalarDataTransform } from "../data-transform.jsx"; +import { + getJsonArrayTransformRefkey, + JsonArrayTransform, + JsonArrayTransformDeclaration, +} from "./json-array-transform.jsx"; +import { JsonModelPropertyTransform } from "./json-model-property-transform.jsx"; +import { + getJsonModelTransformRefkey, + JsonModelTransform, + JsonModelTransformDeclaration, +} from "./json-model-transform.jsx"; +import { + getJsonRecordTransformRefkey, + JsonRecordTransform, + JsonRecordTransformDeclaration, +} from "./json-record-transform.jsx"; +import { + getJsonUnionTransformRefkey, + JsonUnionTransform, + JsonUnionTransformDeclaration, +} from "./union-transform.jsx"; + +export interface JsonTransformProps { + itemRef: ay.Refkey | ay.Children; + type: Type; + target: "transport" | "application"; +} + +export function JsonTransform(props: JsonTransformProps) { + const type = $.httpPart.unpack(props.type) ?? props.type; + const declaredTransform = getTransformReference(type, props.target); + + if (declaredTransform) { + return ay.code`${declaredTransform}(${props.itemRef})`; + } + + switch (type.kind) { + case "Model": { + if ($.array.is(type)) { + return ; + } + + if ($.record.is(type)) { + return ; + } + + return ; + } + case "Union": + return ; + case "ModelProperty": { + return ; + } + case "Scalar": { + return ; + } + default: + return props.itemRef; + } +} + +export interface JsonTransformDeclarationProps { + type: Type; + target: "transport" | "application"; +} + +export function JsonTransformDeclaration(props: JsonTransformDeclarationProps) { + if (!$.model.is(props.type) && !$.union.is(props.type)) { + return null; + } + + if ($.model.is(props.type)) { + if ($.array.is(props.type)) { + return ; + } + + if ($.record.is(props.type)) { + return ; + } + + return ; + } + + if ($.union.is(props.type)) { + return ; + } +} + +function getTransformReference( + type: Type, + target: "transport" | "application", +): ay.Refkey | undefined { + if (type.kind === "Model" && Boolean(type.name)) { + if ($.array.is(type)) { + return getJsonArrayTransformRefkey(type, target); + } + + if ($.record.is(type)) { + return getJsonRecordTransformRefkey(type, target); + } + return getJsonModelTransformRefkey(type, target); + } + + if (type.kind === "Union" && Boolean(type.name)) { + return getJsonUnionTransformRefkey(type, target); + } + + return undefined; +} diff --git a/packages/http-client-js/src/components/transforms/json/union-transform.tsx b/packages/http-client-js/src/components/transforms/json/union-transform.tsx new file mode 100644 index 00000000000..f41a017da5a --- /dev/null +++ b/packages/http-client-js/src/components/transforms/json/union-transform.tsx @@ -0,0 +1,73 @@ +import * as ay from "@alloy-js/core"; +import * as ts from "@alloy-js/typescript"; +import { Union } from "@typespec/compiler"; +import { $ } from "@typespec/compiler/experimental/typekit"; +import { + getJsonTransformDiscriminatorRefkey, + JsonTransformDiscriminatorDeclaration, +} from "./json-transform-discriminator.jsx"; +import { JsonTransform } from "./json-transform.jsx"; + +export interface JsonUnionTransformProps { + itemRef: ay.Refkey | ay.Children; + type: Union; + target: "transport" | "application"; +} + +export function JsonUnionTransform(props: JsonUnionTransformProps) { + const discriminator = $.type.getDiscriminator(props.type); + if (discriminator) { + // return ; + return <>{getJsonTransformDiscriminatorRefkey(props.type, props.target)}({props.itemRef}); + } + + const variantType = props.type.variants.values().next().value!.type; + + if (!$.union.isExtensible(props.type)) { + return props.itemRef; + } + + // TODO: Handle non-discriminated unions + return ; +} + +export function getJsonUnionTransformRefkey( + type: Union, + target: "transport" | "application", +): ay.Refkey { + return ay.refkey(type, "json_union_transform", target); +} +export interface JsonUnionTransformDeclarationProps { + type: Union; + target: "transport" | "application"; +} + +export function JsonUnionTransformDeclaration(props: JsonUnionTransformDeclarationProps) { + const namePolicy = ts.useTSNamePolicy(); + const transformName = namePolicy.getName( + `json_${props.type.name}_to_${props.target}_transform`, + "function", + ); + + const typeRef = ay.refkey(props.type); + const returnType = props.target === "transport" ? "any" : typeRef; + const inputType = props.target === "transport" ? <>{typeRef} | null : "any"; + const inputRef = ay.refkey(); + + const parameters: Record = { + input_: { type: inputType, refkey: inputRef, optional: true }, + }; + + const declarationRefkey = getJsonUnionTransformRefkey(props.type, props.target); + return <> + + + {ay.code` + if(!${inputRef}) { + return ${inputRef} as any; + } + `} + return + + ; +} diff --git a/packages/http-client-js/src/components/transforms/json/utils/transport-namer.ts b/packages/http-client-js/src/components/transforms/json/utils/transport-namer.ts new file mode 100644 index 00000000000..4f4b01f91f7 --- /dev/null +++ b/packages/http-client-js/src/components/transforms/json/utils/transport-namer.ts @@ -0,0 +1,12 @@ +import { Type } from "@typespec/compiler"; +import { $ } from "@typespec/compiler/experimental/typekit"; +import { reportDiagnostic } from "../../../../lib.js"; + +export function getJsonTransportName(type: Type) { + if (!("name" in type)) { + reportDiagnostic($.program, { code: "no-name-type", target: type }); + return ""; + } + + return $.type.getEncodedName(type as any, "json") ?? type.name; +} diff --git a/packages/http-client-js/src/components/transforms/multipart/array-part-transform.tsx b/packages/http-client-js/src/components/transforms/multipart/array-part-transform.tsx new file mode 100644 index 00000000000..6de4c5a289b --- /dev/null +++ b/packages/http-client-js/src/components/transforms/multipart/array-part-transform.tsx @@ -0,0 +1,40 @@ +import * as ay from "@alloy-js/core"; +import * as ts from "@alloy-js/typescript"; +import { HttpOperationPart } from "@typespec/http"; +import { HttpPartTransform } from "./part-transform.jsx"; + +export interface ArrayPartTransformProps { + part: HttpOperationPart; + itemRef: ay.Children; +} + +export function ArrayPartTransform(props: ArrayPartTransformProps) { + const namePolicy = ts.useTSNamePolicy(); + const applicationName = namePolicy.getName(props.part.name!, "variable"); + const partElementName = applicationName; + const mapCallbackSignature = <>({partElementName}: any); + const partItemRef = getPartRef(props.itemRef, applicationName); + const inputExpression = props.part.optional ? ( + <>...({partItemRef} ?? []) + ) : ( + <>...{partItemRef} + ); + + const partElement: HttpOperationPart = { + ...props.part, + multi: false, + name: partElementName, + }; + + return <> + {inputExpression}.map({mapCallbackSignature} {ay.code`=> (${})`} ) + ; +} + +function getPartRef(itemRef: ay.Children, partName: string) { + if (itemRef === null) { + return partName; + } + + return ay.code`${itemRef}.${partName}`; +} diff --git a/packages/http-client-js/src/components/transforms/multipart/file-part-transform.tsx b/packages/http-client-js/src/components/transforms/multipart/file-part-transform.tsx new file mode 100644 index 00000000000..5a9dbf84a63 --- /dev/null +++ b/packages/http-client-js/src/components/transforms/multipart/file-part-transform.tsx @@ -0,0 +1,44 @@ +import * as ay from "@alloy-js/core"; +import * as ts from "@alloy-js/typescript"; +import { HttpOperationPart } from "@typespec/http"; +import { getCreateFilePartDescriptorReference } from "../../static-helpers/multipart-helpers.jsx"; + +export interface FilePartTransformProps { + part: HttpOperationPart; + itemRef: ay.Children; +} + +export function FilePartTransform(props: FilePartTransformProps) { + const namePolicy = ts.useTSNamePolicy(); + const defaultContentType = getContentType(props.part); + const applicationName = namePolicy.getName(props.part.name!, "variable"); + const itemRef = getPartRef(props.itemRef, applicationName); + const args: ay.Children = [JSON.stringify(props.part.name), itemRef]; + if (defaultContentType) { + args.push(ts.ValueExpression({ jsValue: defaultContentType })); + } + return ; +} + +function getContentType(part: HttpOperationPart) { + const contentTypes = part.body.contentTypes; + if (contentTypes.length !== 1) { + return undefined; + } + + const contentType = contentTypes[0]; + + if (!contentType || contentType === "*/*") { + return undefined; + } + + return contentType; +} + +function getPartRef(itemRef: ay.Children, partName: string) { + if (itemRef === null) { + return partName; + } + + return ay.code`${itemRef}.${partName}`; +} diff --git a/packages/http-client-js/src/components/transforms/multipart/multipart-transform.tsx b/packages/http-client-js/src/components/transforms/multipart/multipart-transform.tsx new file mode 100644 index 00000000000..459827a5be8 --- /dev/null +++ b/packages/http-client-js/src/components/transforms/multipart/multipart-transform.tsx @@ -0,0 +1,32 @@ +import * as ay from "@alloy-js/core"; +import { $ } from "@typespec/compiler/experimental/typekit"; +import { useTransformNamePolicy } from "@typespec/emitter-framework"; +import { HttpOperationMultipartBody } from "@typespec/http"; +import { reportDiagnostic } from "../../../lib.js"; +import { HttpPartTransform } from "./part-transform.jsx"; + +export interface MultipartTransformProps { + body: HttpOperationMultipartBody; +} + +export function MultipartTransform(props: MultipartTransformProps) { + const transportNamer = useTransformNamePolicy(); + const httpParts = props.body.parts; + + if (httpParts.length === 0) { + reportDiagnostic($.program, { code: "missing-http-parts", target: props.body.property }); + return <>[]; + } + + const itemRef = transportNamer.getApplicationName(props.body.property); + + const partTransform = ay.mapJoin( + httpParts, + (part) => , + { + joiner: ",\n", + }, + ); + + return <>[{partTransform}]; +} diff --git a/packages/http-client-js/src/components/transforms/multipart/part-transform.tsx b/packages/http-client-js/src/components/transforms/multipart/part-transform.tsx new file mode 100644 index 00000000000..e4254cd9f19 --- /dev/null +++ b/packages/http-client-js/src/components/transforms/multipart/part-transform.tsx @@ -0,0 +1,22 @@ +import * as ay from "@alloy-js/core"; +import { HttpOperationPart } from "@typespec/http"; +import { ArrayPartTransform } from "./array-part-transform.jsx"; +import { FilePartTransform } from "./file-part-transform.jsx"; +import { SimplePartTransform } from "./simple-part-transform.jsx"; + +export interface HttpPartTransformProps { + part: HttpOperationPart; + itemRef: ay.Children; +} + +export function HttpPartTransform(props: HttpPartTransformProps) { + if (props.part.multi) { + return ; + } + + if (props.part.filename) { + return ; + } + + return ; +} diff --git a/packages/http-client-js/src/components/transforms/multipart/simple-part-transform.tsx b/packages/http-client-js/src/components/transforms/multipart/simple-part-transform.tsx new file mode 100644 index 00000000000..795b007010b --- /dev/null +++ b/packages/http-client-js/src/components/transforms/multipart/simple-part-transform.tsx @@ -0,0 +1,49 @@ +import * as ay from "@alloy-js/core"; +import * as ts from "@alloy-js/typescript"; +import { $ } from "@typespec/compiler/experimental/typekit"; +import { HttpOperationPart } from "@typespec/http"; +import { reportDiagnostic } from "../../../lib.js"; +import { JsonTransform } from "../json/json-transform.jsx"; + +export interface SimplePartTransformProps { + part: HttpOperationPart; + itemRef: ay.Children; +} + +export function SimplePartTransform(props: SimplePartTransformProps) { + const namePolicy = ts.useTSNamePolicy(); + const applicationName = namePolicy.getName(props.part.name!, "variable"); + const partName = ts.ValueExpression({ jsValue: props.part.name }); + const partContentType = props.part.body.contentTypes[0] ?? "application/json"; + const partRef = getPartRef(props.itemRef, applicationName); + + if (!partContentType.startsWith("application/json")) { + reportDiagnostic($.program, { + code: "unsupported-content-type", + target: props.part.body.type, + message: `Unsupported content type: ${partContentType}`, + }); + } + + return + , + + } + />, + ; +} + +function getPartRef(itemRef: ay.Children, partName: string) { + if (itemRef === null) { + return partName; + } + + return ay.code`${itemRef}.${partName}`; +} diff --git a/packages/http-client-js/src/components/transforms/operation-transform-declaration.tsx b/packages/http-client-js/src/components/transforms/operation-transform-declaration.tsx new file mode 100644 index 00000000000..298b540295e --- /dev/null +++ b/packages/http-client-js/src/components/transforms/operation-transform-declaration.tsx @@ -0,0 +1,64 @@ +import * as ay from "@alloy-js/core"; +import * as ts from "@alloy-js/typescript"; +import * as ef from "@typespec/emitter-framework/typescript"; +import { HttpOperationBody } from "@typespec/http"; +import { ClientOperation } from "@typespec/http-client"; +import { JsonTransform } from "./json/json-transform.jsx"; +export interface TransformDeclarationProps { + operation: ClientOperation; + refkey?: ay.Refkey; +} + +export function getTransformDeclarationRef(operaion: ClientOperation) { + if (operaion.httpOperation.parameters.body?.bodyKind === "single") { + return ef.getTypeTransformerRefkey(operaion.httpOperation.parameters.body.type, "transport"); + } + return ay.refkey(operaion, "transform"); +} + +export function TransformDeclaration(props: TransformDeclarationProps) { + const refkey = props.refkey ?? getTransformDeclarationRef(props.operation); + return <> + + ; +} + +export interface SingleBodyTransformDeclarationProps { + name: string; + operation: ClientOperation; + payload: HttpOperationBody; + refkey: ay.Refkey; +} + +export function SingleBodyTransformDeclaration(props: SingleBodyTransformDeclarationProps) { + const inputRef = ay.refkey(props.payload, "property"); + const payloadParameter: ts.ParameterDescriptor = { + type: , + refkey: inputRef, + }; + + return + return !; + ; +} + +interface TransformToTransportDeclarationProps { + operation: ClientOperation; + refkey: ay.Refkey; +} + +function TransformToTransportDeclaration(props: TransformToTransportDeclarationProps) { + const requestPayload = props.operation.httpOperation.parameters.body; + if (!requestPayload || !requestPayload.property) { + return; + } + + const namePolicy = ts.useTSNamePolicy(); + const name = namePolicy.getName(`${props.operation.name}_payload_to_transport`, "function"); + + if (requestPayload.bodyKind === "multipart") { + return null; + } + + return ; +} diff --git a/packages/http-client-js/src/components/transforms/operation-transform-expression.tsx b/packages/http-client-js/src/components/transforms/operation-transform-expression.tsx new file mode 100644 index 00000000000..713f95fb66b --- /dev/null +++ b/packages/http-client-js/src/components/transforms/operation-transform-expression.tsx @@ -0,0 +1,54 @@ +import * as ts from "@alloy-js/typescript"; +import { Type } from "@typespec/compiler"; +import { $ } from "@typespec/compiler/experimental/typekit"; +import { HasName, TransformNamePolicyContext } from "@typespec/emitter-framework"; +import { ClientOperation } from "@typespec/http-client"; +import { reportDiagnostic } from "../../lib.js"; +import { ContentTypeEncodingProvider } from "./content-type-encoding-provider.jsx"; +import { JsonTransform } from "./json/json-transform.jsx"; +import { MultipartTransform } from "./multipart/multipart-transform.jsx"; +import { defaultTransportNameGetter } from "./transform-name-policy.js"; + +export interface OperationTransformToTransportExpression { + operation: ClientOperation; +} + +export function OperationTransformExpression(props: OperationTransformToTransportExpression) { + const body = props.operation.httpOperation.parameters.body; + + if (!body) { + return; + } + + const itemRef = body.property ? payloadApplicationNameGetter(body.property) : null; + + if (body.bodyKind === "multipart") { + return ; + } + + // TODO: Handle content types other than application/json and multipart + // And multiple content types + const contentType = body.contentTypes[0]; + const payloadType = body.type; + return + + + + ; +} + +function payloadApplicationNameGetter(type: HasName) { + if (typeof type.name !== "string") { + reportDiagnostic($.program, { code: "symbol-name-not-supported", target: type }); + return ""; + } + + const namePolicy = ts.useTSNamePolicy(); + let name = namePolicy.getName(type.name, "object-member-data"); + + if ($.modelProperty.is(type) && type.optional) { + name = `options?.${name}`; + } + + return name; +} diff --git a/packages/http-client-js/src/components/transforms/scalar-transform.tsx b/packages/http-client-js/src/components/transforms/scalar-transform.tsx new file mode 100644 index 00000000000..25a476b39b3 --- /dev/null +++ b/packages/http-client-js/src/components/transforms/scalar-transform.tsx @@ -0,0 +1,289 @@ +import { Children, code, Refkey } from "@alloy-js/core"; +import { BytesKnownEncoding, EncodeData, NoTarget, Scalar } from "@typespec/compiler"; +import { $ } from "@typespec/compiler/experimental/typekit"; +import * as ef from "@typespec/emitter-framework/typescript"; +import { useDefaultEncoding } from "../../context/encoding/encoding-context.jsx"; +import { reportDiagnostic } from "../../lib.js"; +import { + getDecodeUint8ArrayRef, + getEncodeUint8ArrayRef, +} from "../static-helpers/bytes-encoding.jsx"; + +// Define the transformer function type. +export type TransformerFn = (itemRef: Refkey | Children, encoding?: EncodeData) => Children; + +// Define a pair that holds both directions. +export interface TransformerPair { + toTransport: TransformerFn; + toApplication: TransformerFn; +} + +/** + * Returns the transformer pair for the given scalar type. + * + * @param type - The scalar type to look up. + * @returns The transformer pair from scalarTransformerMap for the given type. + */ +export function getScalarTransformer(type: Scalar): TransformerPair { + return scalarTransformerMap[getScalarTransformKey(type)]; +} + +function defineScalarTransformerMap>(map: T): T { + return map; +} + +/** + * A paired transformer map for scalar types. + * + * Each scalar type is associated with a pair of functions: one for transforming the value for transport + * (serialization) and one for transforming the value for application (deserialization). + */ +const scalarTransformerMap = defineScalarTransformerMap({ + boolean: { + toTransport: (itemRef) => passthroughTransformer(itemRef), + toApplication: (itemRef) => passthroughTransformer(itemRef), + }, + + bytes: { + toTransport: (itemRef, encoding) => { + const bytesEncoding: BytesKnownEncoding | undefined = + (encoding?.encoding as BytesKnownEncoding) ?? + (useDefaultEncoding("bytes") as BytesKnownEncoding); + switch (bytesEncoding) { + case "base64": + case "base64url": + const bytesEncodeRef = getEncodeUint8ArrayRef(); + return code`${bytesEncodeRef}(${itemRef}, "${bytesEncoding}")!`; + default: + reportDiagnostic($.program, { + code: "unknown-encoding", + target: encoding?.type ?? NoTarget, + }); + return passthroughTransformer(itemRef); + } + }, + toApplication: (itemRef, encoding) => { + const bytesEncoding: BytesKnownEncoding | undefined = + (encoding?.encoding as BytesKnownEncoding) ?? + (useDefaultEncoding("bytes") as BytesKnownEncoding); + + switch (bytesEncoding) { + case "base64": + case "base64url": + const bytesDecodeRef = getDecodeUint8ArrayRef(); + return code`${bytesDecodeRef}(${itemRef})!`; + default: + reportDiagnostic($.program, { + code: "unknown-encoding", + target: encoding?.type ?? NoTarget, + }); + return passthroughTransformer(itemRef); + } + }, + }, + + // For numerics that have no transformation, both directions simply passthrough. + decimal: { + toTransport: (itemRef) => passthroughTransformer(itemRef), + toApplication: (itemRef) => passthroughTransformer(itemRef), + }, + decimal128: { + toTransport: (itemRef) => passthroughTransformer(itemRef), + toApplication: (itemRef) => passthroughTransformer(itemRef), + }, + float: { + toTransport: (itemRef) => passthroughTransformer(itemRef), + toApplication: (itemRef) => passthroughTransformer(itemRef), + }, + float32: { + toTransport: (itemRef) => passthroughTransformer(itemRef), + toApplication: (itemRef) => passthroughTransformer(itemRef), + }, + float64: { + toTransport: (itemRef) => passthroughTransformer(itemRef), + toApplication: (itemRef) => passthroughTransformer(itemRef), + }, + int16: { + toTransport: (itemRef) => passthroughTransformer(itemRef), + toApplication: (itemRef) => passthroughTransformer(itemRef), + }, + int32: { + toTransport: (itemRef) => passthroughTransformer(itemRef), + toApplication: (itemRef) => passthroughTransformer(itemRef), + }, + int64: { + toTransport: (itemRef) => passthroughTransformer(itemRef), + toApplication: (itemRef) => passthroughTransformer(itemRef), + }, + int8: { + toTransport: (itemRef) => passthroughTransformer(itemRef), + toApplication: (itemRef) => passthroughTransformer(itemRef), + }, + integer: { + toTransport: (itemRef) => passthroughTransformer(itemRef), + toApplication: (itemRef) => passthroughTransformer(itemRef), + }, + numeric: { + toTransport: (itemRef) => passthroughTransformer(itemRef), + toApplication: (itemRef) => passthroughTransformer(itemRef), + }, + safeint: { + toTransport: (itemRef) => passthroughTransformer(itemRef), + toApplication: (itemRef) => passthroughTransformer(itemRef), + }, + uint16: { + toTransport: (itemRef) => passthroughTransformer(itemRef), + toApplication: (itemRef) => passthroughTransformer(itemRef), + }, + uint32: { + toTransport: (itemRef) => passthroughTransformer(itemRef), + toApplication: (itemRef) => passthroughTransformer(itemRef), + }, + uint64: { + toTransport: (itemRef) => passthroughTransformer(itemRef), + toApplication: (itemRef) => passthroughTransformer(itemRef), + }, + uint8: { + toTransport: (itemRef) => passthroughTransformer(itemRef), + toApplication: (itemRef) => passthroughTransformer(itemRef), + }, + + // Duration: We don't apply any transformation so just passthrough. + duration: { + toTransport: (itemRef) => passthroughTransformer(itemRef), + toApplication: (itemRef) => passthroughTransformer(itemRef), + }, + + // Date/Time scalars: using a serializer on transport and a deserializer on application. + offsetDateTime: { + toTransport: (itemRef) => passthroughTransformer(itemRef), + toApplication: (itemRef) => passthroughTransformer(itemRef), + }, + plainDate: { + toTransport: (itemRef) => passthroughTransformer(itemRef), + toApplication: (itemRef) => passthroughTransformer(itemRef), + }, + plainTime: { + toTransport: (itemRef) => passthroughTransformer(itemRef), + toApplication: (itemRef) => passthroughTransformer(itemRef), + }, + utcDateTime: { + toTransport: (itemRef, encoding) => { + const dateEncoding = encoding?.encoding ?? useDefaultEncoding("datetime"); + let encodingFnRef: Refkey = ef.DateRfc3339SerializerRefkey; + switch (dateEncoding) { + case "unixTimestamp": + encodingFnRef = ef.DateUnixTimestampSerializerRefkey; + break; + case "rfc7231": + encodingFnRef = ef.DateRfc7231SerializerRefkey; + break; + case "rfc3339": + // already defaulted above. + break; + default: + reportDiagnostic($.program, { + code: "unknown-encoding", + target: encoding?.type ?? NoTarget, + }); + } + return code`${encodingFnRef}(${itemRef})`; + }, + toApplication: (itemRef, encoding) => { + const dateEncoding = encoding?.encoding ?? useDefaultEncoding("datetime"); + let decodingFnRef: Refkey = ef.DateDeserializerRefkey; + switch (dateEncoding) { + case "unixTimestamp": + decodingFnRef = ef.DateUnixTimestampDeserializerRefkey; + break; + case "rfc7231": + decodingFnRef = ef.DateRfc7231DeserializerRefkey; + break; + case "rfc3339": + // default already + break; + default: + reportDiagnostic($.program, { + code: "unknown-encoding", + target: encoding?.type ?? NoTarget, + }); + } + return code`${decodingFnRef}(${itemRef})!`; + }, + }, + + unixTimestamp32: { + toTransport: (itemRef) => { + return code`${ef.DateUnixTimestampSerializerRefkey}(${itemRef})`; + }, + toApplication: (itemRef) => { + return code`${ef.DateUnixTimestampDeserializerRefkey}(${itemRef})`; + }, + }, + + // For URL and string, no transformation is needed. + url: { + toTransport: (itemRef) => passthroughTransformer(itemRef), + toApplication: (itemRef) => passthroughTransformer(itemRef), + }, + string: { + toTransport: (itemRef) => passthroughTransformer(itemRef), + toApplication: (itemRef) => passthroughTransformer(itemRef), + }, +}); + +/** + * Tests for determining the scalar transformation key. + */ +type ScalarTest = (type: Scalar) => boolean; + +const scalarTests: { key: keyof typeof scalarTransformerMap; test: ScalarTest }[] = [ + { key: "boolean", test: (t) => $.scalar.isBoolean(t) || $.scalar.extendsBoolean(t) }, + { key: "bytes", test: (t) => $.scalar.isBytes(t) || $.scalar.extendsBytes(t) }, + { key: "decimal", test: (t) => $.scalar.isDecimal(t) || $.scalar.extendsDecimal(t) }, + { key: "decimal128", test: (t) => $.scalar.isDecimal128(t) || $.scalar.extendsDecimal128(t) }, + { key: "duration", test: (t) => $.scalar.isDuration(t) || $.scalar.extendsDuration(t) }, + { key: "float", test: (t) => $.scalar.isFloat(t) || $.scalar.extendsFloat(t) }, + { key: "float32", test: (t) => $.scalar.isFloat32(t) || $.scalar.extendsFloat32(t) }, + { key: "float64", test: (t) => $.scalar.isFloat64(t) || $.scalar.extendsFloat64(t) }, + { key: "int16", test: (t) => $.scalar.isInt16(t) || $.scalar.extendsInt16(t) }, + { key: "int32", test: (t) => $.scalar.isInt32(t) || $.scalar.extendsInt32(t) }, + { key: "int64", test: (t) => $.scalar.isInt64(t) || $.scalar.extendsInt64(t) }, + { key: "int8", test: (t) => $.scalar.isInt8(t) || $.scalar.extendsInt8(t) }, + { key: "integer", test: (t) => $.scalar.isInteger(t) || $.scalar.extendsInteger(t) }, + { key: "numeric", test: (t) => $.scalar.isNumeric(t) || $.scalar.extendsNumeric(t) }, + { + key: "offsetDateTime", + test: (t) => $.scalar.isOffsetDateTime(t) || $.scalar.extendsOffsetDateTime(t), + }, + { key: "plainDate", test: (t) => $.scalar.isPlainDate(t) || $.scalar.extendsPlainDate(t) }, + { key: "plainTime", test: (t) => $.scalar.isPlainTime(t) || $.scalar.extendsPlainTime(t) }, + { key: "safeint", test: (t) => $.scalar.isSafeint(t) || $.scalar.extendsSafeint(t) }, + { key: "string", test: (t) => $.scalar.isString(t) || $.scalar.extendsString(t) }, + { key: "uint16", test: (t) => $.scalar.isUint16(t) || $.scalar.extendsUint16(t) }, + { key: "uint32", test: (t) => $.scalar.isUint32(t) || $.scalar.extendsUint32(t) }, + { key: "uint64", test: (t) => $.scalar.isUint64(t) || $.scalar.extendsUint64(t) }, + { key: "uint8", test: (t) => $.scalar.isUint8(t) || $.scalar.extendsUint8(t) }, + { key: "url", test: (t) => $.scalar.isUrl(t) || $.scalar.extendsUrl(t) }, + { key: "utcDateTime", test: (t) => $.scalar.isUtcDateTime(t) || $.scalar.extendsUtcDateTime(t) }, +]; + +/** + * Determines the key to use in the scalarTransformerMap for the given scalar type. + * + * @param type - The scalar type to check. + * @returns The corresponding key from the scalarTransformerMap. + * @throws Error if the scalar type is unknown. + */ +function getScalarTransformKey(type: Scalar): keyof typeof scalarTransformerMap { + for (const { key, test } of scalarTests) { + if (test(type)) { + return key; + } + } + throw new Error(`Unknown scalar type: ${type}`); +} + +function passthroughTransformer(itemRef: Refkey | Children) { + return itemRef; +} diff --git a/packages/http-client-js/src/components/transforms/transform-name-policy.ts b/packages/http-client-js/src/components/transforms/transform-name-policy.ts new file mode 100644 index 00000000000..28e824fd6de --- /dev/null +++ b/packages/http-client-js/src/components/transforms/transform-name-policy.ts @@ -0,0 +1,133 @@ +import * as ts from "@alloy-js/typescript"; +import { Type } from "@typespec/compiler"; +import { $ } from "@typespec/compiler/experimental/typekit"; +import * as ef from "@typespec/emitter-framework"; +import { kebabCase } from "change-case"; +import { reportDiagnostic } from "../../lib.js"; + +export function useTransformNamePolicy(): ef.TransformNamePolicy { + return ef.useTransformNamePolicy(); +} + +/** + * A type that extends `Type` and includes a `name` property of type `string`. + * This type is used to represent objects that specifically have a `name` property of type `string`. + */ +export type WithStringName = Type & { name: string }; + +/** + * A type that extends `Type` and includes a `name` property of type `string | symbol`. + * This type is used to represent objects that have a `name` property which can either be a `string` or `symbol`. + */ +export type WithName = Type & { name: string | symbol }; + +/** + * A set of optional namers that can be provided for transforming transport and application names. + * The `transportNamer` transforms the transport name, while `applicationNamer` transforms the application name. + */ +export interface TransformNamers { + /** + * Function to generate a transport name from a `Type` object. + * + * @param type - The type object to transform. + * @param encoding - An optional encoding string to modify the transformation. + * @returns A transformed transport name as a string. + */ + transportNamer?: (type: WithName, encoding?: string) => string; + + /** + * Function to generate an application name from a `Type` object. + * + * @param type - The type object to transform. + * @returns A transformed application name as a string. + */ + applicationNamer?: (type: WithName) => string; +} + +/** + * Factory function to create a `TransformNamePolicy` implementation with custom or default name transformation logic. + * This function uses the provided namers for transport and application names. If no namers are provided, + * default implementations are used. + * + * @param namers - An optional object containing custom `transportNamer` and `applicationNamer` functions. + * @returns A `TransformNamePolicy` implementation. + */ +export function createTransformNamePolicy(namers: TransformNamers = {}): ef.TransformNamePolicy { + const transportNamer = namers.transportNamer ?? defaultTransportNameGetter; + const applicationNamer = namers.applicationNamer ?? defaultApplicationNameGetter; + + return { + /** + * Transforms the transport name based on the provided `transportNamer` function or the default. + * If the `type` does not have a string `name`, it reports a diagnostic. + * + * @param type - The object that has a `name` property (either string or symbol). + * @returns The transformed transport name as a string. + */ + getTransportName(type) { + return transportNamer(type); + }, + + /** + * Transforms the application name based on the provided `applicationNamer` function or the default. + * If the `type` does not have a string `name`, it reports a diagnostic. + * + * @param type - The object that has a `name` property (either string or symbol). + * @returns The transformed application name as a string. + */ + getApplicationName(type) { + return applicationNamer(type); + }, + }; +} + +/** + * Default function for transforming the application name. It assumes that the `type` has a `name` of type `string`. + * + * @param type - The type object that must have a `name` property of type `string`. + * @returns The transformed application name as a string. + */ +export function defaultApplicationNameGetter(type: WithName): string { + if (!hasStringName(type)) { + reportDiagnostic($.program, { code: "symbol-name-not-supported", target: type }); + return ""; + } + + const namePolicy = ts.useTSNamePolicy(); + const name = type.name; + return namePolicy.getName(name, "object-member-data"); +} + +/** + * Default function for transforming the transport name. It assumes that the `type` has a `name` of type `string`. + * Optionally uses an `encoding` string to modify the name. + * + * @param type - The type object that must have a `name` property of type `string`. + * @param encoding - Optional encoding for the transport name. + * @returns The transformed transport name as a string. + */ +export function defaultTransportNameGetter( + type: WithName, + encoding: string = "application/json", +): string { + if (!hasStringName(type)) { + reportDiagnostic($.program, { code: "symbol-name-not-supported", target: type }); + return ""; + } + let name = encoding ? $.type.getEncodedName(type, encoding) : type.name; + + if ($.modelProperty.is(type) && $.modelProperty.isHttpHeader(type)) { + name = kebabCase(name); + } + return name; +} + +/** + * Helper function to check if the provided `type` has a `name` property of type `string`. + * + * @param type - The type object that may have a `name` property of type `string` or `symbol`. + * @returns A boolean indicating whether the `name` property is a string. + */ +export function hasStringName(type: WithName): type is WithStringName { + return typeof type.name === "string"; +} diff --git a/packages/http-client-js/src/components/utils/unpack-model-property.ts b/packages/http-client-js/src/components/utils/unpack-model-property.ts new file mode 100644 index 00000000000..ed90cba7262 --- /dev/null +++ b/packages/http-client-js/src/components/utils/unpack-model-property.ts @@ -0,0 +1,31 @@ +import { isNullType, ModelProperty, Type } from "@typespec/compiler"; +import { $ } from "@typespec/compiler/experimental/typekit"; + +/** + * Sometimes a model property type would be another ModelProperty in this case we need to keep unpacking until we find a non Model Property + * type to get to the actual type. + * This also handles an HttpPart which needs to be unpacked as well. + */ +export function unpackProperty(modelProperty: ModelProperty): Type { + const type = $.httpPart.unpack(modelProperty.type) ?? modelProperty.type; + if ($.modelProperty.is(type)) { + return unpackProperty(type); + } + + // Check if it is nullable + if ($.union.is(type)) { + const variants = Array.from(type.variants.values()); + const nullVariant = variants.find((v) => isNullType(v.type)); + if (variants.length === 2 && nullVariant) { + // When the union has only null and another variant unpack the non-null type + return variants.find((v) => v !== nullVariant)!.type; + } + + if (variants.length > 2 && nullVariant) { + const nonNullVariants = variants.filter((v) => v !== nullVariant); + return $.union.create({ name: type.name, variants: nonNullVariants }); + } + } + + return type; +} diff --git a/packages/http-client-js/src/components/utils/url-template.tsx b/packages/http-client-js/src/components/utils/url-template.tsx new file mode 100644 index 00000000000..372a39c1261 --- /dev/null +++ b/packages/http-client-js/src/components/utils/url-template.tsx @@ -0,0 +1,21 @@ +import * as ts from "@alloy-js/typescript"; +import { ModelProperty } from "@typespec/compiler"; +import { useTransformNamePolicy } from "@typespec/emitter-framework"; +import { uriTemplateLib } from "../external-packages/uri-template.js"; + +export interface UrlTemplateProps { + template: string; + parameters: ModelProperty[]; +} + +export function UrlTemplate(props: UrlTemplateProps) { + const namer = useTransformNamePolicy(); + const params = props.parameters.map((p) => { + return + "{namer.getTransportName(p)}": {namer.getApplicationName(p)} + ; + }); + return + {uriTemplateLib.parse}({JSON.stringify(props.template)}).expand({params}) + ; +} diff --git a/packages/http-client-js/src/context/encoding/encoding-context.tsx b/packages/http-client-js/src/context/encoding/encoding-context.tsx new file mode 100644 index 00000000000..679a9284577 --- /dev/null +++ b/packages/http-client-js/src/context/encoding/encoding-context.tsx @@ -0,0 +1,26 @@ +import { ComponentContext, createNamedContext, useContext } from "@alloy-js/core"; +import { NoTarget } from "@typespec/compiler"; +import { $ } from "@typespec/compiler/experimental/typekit"; +import { reportDiagnostic } from "../../lib.js"; +import { EncodingDefaults } from "./types.js"; + +export const EncodingContext: ComponentContext = + createNamedContext("Encoding"); + +export function useEncoding() { + const context = useContext(EncodingContext); + + if (!context) { + reportDiagnostic($.program, { + code: "use-encoding-context-without-provider", + target: NoTarget, + }); + } + + return context!; +} + +export function useDefaultEncoding(scalarType: keyof EncodingDefaults): string | undefined { + const defaults = useEncoding(); + return defaults[scalarType]; +} diff --git a/packages/http-client-js/src/context/encoding/types.ts b/packages/http-client-js/src/context/encoding/types.ts new file mode 100644 index 00000000000..1357d163750 --- /dev/null +++ b/packages/http-client-js/src/context/encoding/types.ts @@ -0,0 +1,6 @@ +export type ScalarEncoding = { + bytes?: "base64" | "base64url" | "none"; + datetime?: "rfc3339" | "unixTimestamp" | "rfc7231"; +}; + +export type EncodingDefaults = Partial; diff --git a/packages/http-client-js/src/emitter.tsx b/packages/http-client-js/src/emitter.tsx new file mode 100644 index 00000000000..9817b023c8c --- /dev/null +++ b/packages/http-client-js/src/emitter.tsx @@ -0,0 +1,53 @@ +import * as ay from "@alloy-js/core"; +import * as ts from "@alloy-js/typescript"; +import { EmitContext } from "@typespec/compiler"; +import { writeOutput } from "@typespec/emitter-framework"; +import { OperationsDirectory } from "./components/client-directory.jsx"; +import { Client } from "./components/client.jsx"; +import { Models } from "./components/models.js"; +import { Output } from "./components/output.jsx"; +import { ModelSerializers } from "./components/serializers.js"; +import { Interfaces } from "./components/static-helpers/interfaces.jsx"; +import { MultipartHelpers } from "./components/static-helpers/multipart-helpers.jsx"; +import { RestError } from "./components/static-helpers/rest-error.jsx"; +import { JsClientEmitterOptions } from "./lib.js"; + +/** + * Main function to handle the emission process. + * @param context - The context for the emission process. + */ +export async function $onEmit(context: EmitContext) { + const packageName = context.options["package-name"] ?? "test-package"; + const output = + + + + + + + + + + + + + + + + + + + + + + + ; + + await writeOutput(output, context.emitterOutputDir); +} diff --git a/packages/http-client-js/src/index.ts b/packages/http-client-js/src/index.ts new file mode 100644 index 00000000000..048dbf59a56 --- /dev/null +++ b/packages/http-client-js/src/index.ts @@ -0,0 +1,5 @@ +export { $onEmit } from "./emitter.js"; +export { $lib } from "./lib.js"; + +import "@typespec/http-client/typekit"; +import "@typespec/http/experimental/typekit"; diff --git a/packages/http-client-js/src/lib.ts b/packages/http-client-js/src/lib.ts new file mode 100644 index 00000000000..76ff8bab58b --- /dev/null +++ b/packages/http-client-js/src/lib.ts @@ -0,0 +1,108 @@ +import { createTypeSpecLibrary, JSONSchemaType } from "@typespec/compiler"; + +export interface JsClientEmitterOptions { + "package-name"?: string; +} + +const EmitterOptionsSchema: JSONSchemaType = { + type: "object", + additionalProperties: true, + properties: { + "package-name": { type: "string", nullable: true, default: "test-package" }, + }, + required: [], +}; + +export const $lib = createTypeSpecLibrary({ + name: "@typespec/http-client-js", + emitter: { + options: EmitterOptionsSchema, + }, + diagnostics: { + "unknown-encoding": { + severity: "warning", + messages: { + default: "Unknown encoding", + }, + }, + "mixed-part-nonpart": { + severity: "warning", + messages: { + default: "Mixed part and non-part properties in model", + }, + }, + "operation-not-in-client": { + severity: "error", + messages: { + default: "Tried to get a client from an operation that is not in a client", + }, + }, + "non-model-parts": { + severity: "error", + messages: { + default: "Non-model parts are not supported", + }, + description: "Non-model parts are not supported", + }, + "multiple-auth-schemes-not-yet-supported": { + severity: "warning", + messages: { + default: "Multiple authentication schemes are not yet supported", + }, + description: + "Multiple authentication schemes are not yet supported. Falling back to the first one.", + }, + "key-credential-non-header-not-implemented": { + severity: "warning", + messages: { + default: "Key credential in query or cookie is not implemented", + }, + description: + "Key credential in query or cookie is not implemented. Falling back to not sending auth details with the requests", + }, + "unsupported-nondiscriminated-union": { + severity: "warning", + messages: { + default: "Unsupported non-discriminated union, skipping deserializer", + }, + }, + "no-name-type": { + severity: "warning", + messages: { + default: "Trying to get a name from a type that doesn't have a name", + }, + }, + "symbol-name-not-supported": { + severity: "error", + messages: { + default: "The transform namer doesn't support symbol names", + }, + }, + "unsupported-content-type": { + severity: "warning", + messages: { + default: "Unsupported content type. Falling back to json", + }, + }, + "missing-http-parts": { + severity: "warning", + messages: { + default: "The operation is defined as a Multipart operation but has no parts", + }, + }, + "use-encoding-context-without-provider": { + severity: "error", + messages: { + default: "Trying to use encoding context without a provider", + }, + }, + "unexpected-non-scalar-type": { + severity: "error", + messages: { + default: "Unexpected non-scalar type when trying to extract Scalar data", + }, + }, + }, +}); + +export const { reportDiagnostic, createDiagnostic } = $lib; diff --git a/packages/http-client-js/src/testing/index.ts b/packages/http-client-js/src/testing/index.ts new file mode 100644 index 00000000000..5148a1bba17 --- /dev/null +++ b/packages/http-client-js/src/testing/index.ts @@ -0,0 +1,8 @@ +import { resolvePath } from "@typespec/compiler"; +import { createTestLibrary, TypeSpecTestLibrary } from "@typespec/compiler/testing"; +import { fileURLToPath } from "url"; + +export const HttpClientJavascriptEmitterTestLibrary: TypeSpecTestLibrary = createTestLibrary({ + name: "@typespec/http-client-js", + packageRoot: resolvePath(fileURLToPath(import.meta.url), "../../../"), +}); diff --git a/packages/http-client-js/src/utils/client-discovery.ts b/packages/http-client-js/src/utils/client-discovery.ts new file mode 100644 index 00000000000..3184e84b018 --- /dev/null +++ b/packages/http-client-js/src/utils/client-discovery.ts @@ -0,0 +1,28 @@ +import * as cl from "@typespec/http-client"; + +const flattenCache: WeakMap = new WeakMap(); +/** + * Flatten the client hierarchy into a single-level array, + * caching the result to avoid recomputing. + */ +export function flattenClients(client: cl.Client): cl.Client[] { + // If we already have a cached value for this client, return it. + if (flattenCache.has(client)) { + return flattenCache.get(client)!; + } + + // Otherwise, do a DFS/BFS to gather all subClients. + const result: cl.Client[] = []; + const stack: cl.Client[] = [client]; + + while (stack.length > 0) { + const current = stack.pop()!; + result.push(current); + // Add sub-clients to the stack + stack.push(...current.subClients); + } + + // Store the result in the cache before returning + flattenCache.set(client, result); + return result; +} diff --git a/packages/http-client-js/src/utils/multipart.ts b/packages/http-client-js/src/utils/multipart.ts new file mode 100644 index 00000000000..92ff18073fa --- /dev/null +++ b/packages/http-client-js/src/utils/multipart.ts @@ -0,0 +1,28 @@ +import { Type } from "@typespec/compiler"; +import { $ } from "@typespec/compiler/experimental/typekit"; +import { reportDiagnostic } from "../lib.js"; + +export function isMultipart(type: Type): boolean { + const body = type; + + if (!$.model.is(body)) { + return false; + } + + let multipartCount = 0; + let nonMultipartCount = 0; + for (const prop of body.properties.values()) { + if ($.httpPart.is(prop.type)) { + multipartCount++; + } else if (!$.array.is(prop.type)) { + nonMultipartCount++; + } + } + + if (multipartCount > 0 && nonMultipartCount > 0) { + reportDiagnostic($.program, { code: "mixed-part-nonpart", target: type }); + return false; + } + + return multipartCount > 0; +} diff --git a/packages/http-client-js/src/utils/operations.ts b/packages/http-client-js/src/utils/operations.ts new file mode 100644 index 00000000000..7ca572624fc --- /dev/null +++ b/packages/http-client-js/src/utils/operations.ts @@ -0,0 +1,109 @@ +import { Model, ModelProperty, Operation, Union } from "@typespec/compiler"; +import { + unsafe_mutateSubgraph as mutateSubgraph, + unsafe_Mutator as Mutator, + unsafe_MutatorFlow as MutatorFlow, + unsafe_MutatorRecord, +} from "@typespec/compiler/experimental"; +import { $ } from "@typespec/compiler/experimental/typekit"; + +/** + * Prepares operation for client representation. This includes adding an options bag for optional parameters. + * @param operation operation to be prepared + * @returns the prepared operation + */ +export function prepareOperation(operation: Operation): Operation { + return mutateSubgraph($.program, [httpParamsMutator], operation).type as Operation; +} + +/** + * Mutates anonymous types to be named. + */ +const anonymousMutatorRecord: unsafe_MutatorRecord = { + filter(t) { + return MutatorFlow.MutateAndRecur; + }, + mutate(t, clone) { + if (!clone.name) { + clone.name = $.type.getPlausibleName(clone); + } + }, +}; + +export const anonymousMutator: Mutator = { + name: "Anonymous types", + Model: anonymousMutatorRecord, + Union: anonymousMutatorRecord, +}; + +/** + * Mutates the operation so that the parameters model is split into required and optional parameters. + */ +export const httpParamsMutator: Mutator = { + name: "Http parameters", + Operation: { + filter() { + return MutatorFlow.DoNotRecur; + }, + mutate(o, clone, _program, realm) { + const httpOperation = $.httpOperation.get(o); + const returnType = $.httpOperation.getReturnType(httpOperation); + clone.returnType = returnType; + const params = $.httpRequest.getParameters(httpOperation, [ + "query", + "header", + "path", + "body", + "contentType", + ]); + + if (!params) { + return; + } + + clone.parameters = params; + + const optionals = [...clone.parameters.properties.values()] + .filter((p) => p.optional) + .reduce( + (acc, prop) => { + acc[prop.name] = prop; + return acc; + }, + {} as Record, + ); + + const optionsBag = $.model.create({ + properties: optionals, + }); + + const optionsProp = $.modelProperty.create({ + name: "options", + type: optionsBag, + optional: true, + }); + + for (const [key, prop] of clone.parameters.properties) { + if (prop.optional || isConstantHeader(prop)) { + clone.parameters.properties.delete(key); + } + } + + if (Object.keys(optionals).length > 0) { + clone.parameters.properties.set("options", optionsProp); + } + }, + }, +}; + +export function isConstantHeader(modelProperty: ModelProperty) { + if (!$.modelProperty.isHttpHeader(modelProperty)) { + return false; + } + + if ("value" in modelProperty.type && modelProperty.type.value !== undefined) { + return true; + } + + return false; +} diff --git a/packages/http-client-js/src/utils/parameters.tsx b/packages/http-client-js/src/utils/parameters.tsx new file mode 100644 index 00000000000..07020e8f07a --- /dev/null +++ b/packages/http-client-js/src/utils/parameters.tsx @@ -0,0 +1,129 @@ +import * as ay from "@alloy-js/core"; +import * as ts from "@alloy-js/typescript"; +import { ModelProperty, Value } from "@typespec/compiler"; +import { $ } from "@typespec/compiler/experimental/typekit"; +import { buildParameterDescriptor } from "@typespec/emitter-framework/typescript"; +import { HttpAuth, HttpProperty } from "@typespec/http"; +import * as cl from "@typespec/http-client"; +import { getClientContextOptionsRef } from "../components/client-context/client-context-options.jsx"; +import { httpRuntimeTemplateLib } from "../components/external-packages/ts-http-runtime.js"; + +export function buildClientParameters(client: cl.Client): Record { + const clientConstructor = $.client.getConstructor(client); + const parameters = $.operation.getClientSignature(client, clientConstructor); + const params = parameters.reduce( + (acc, param) => { + const paramsDescriptor = buildClientParameterDescriptor(param); + if (!paramsDescriptor) { + return acc; + } + const [name, descriptor] = paramsDescriptor; + + if (!descriptor.optional) { + acc[name] = descriptor; + } + + return acc; + }, + {} as Record, + ); + + if (!params["options"]) { + params["options"] = { + refkey: ay.refkey(), + optional: true, + type: getClientContextOptionsRef(client), + }; + } + + return params; +} + +function buildClientParameterDescriptor( + modelProperty: ModelProperty, +): [string, ts.ParameterDescriptor] | undefined { + const authSchemes = $.modelProperty.getCredentialAuth(modelProperty); + + if (authSchemes) { + if (authSchemes.length === 1 && authSchemes[0].type === "noAuth") { + return undefined; + } + + const credentialType = Array.from( + new Set(authSchemes.filter((s) => s.type !== "noAuth").map((s) => getCredentialType(s))), + ); + return [ + "credential", + { + refkey: ay.refkey(modelProperty), + optional: modelProperty.optional, + type: ay.mapJoin(credentialType, (t) => t, { joiner: " | " }), + }, + ]; + } + + return buildParameterDescriptor(modelProperty); +} + +function getCredentialType(scheme: HttpAuth) { + switch (scheme.type) { + case "apiKey": + case "http": + return httpRuntimeTemplateLib.KeyCredential; + case "oauth2": + return httpRuntimeTemplateLib.TokenCredential; + default: + return null; + } +} + +/** + * Checks if a parameter has a default value. Only honors default values for content-type. + * @param property Property to check + * @returns whether the property has a default value + */ +export function hasDefaultValue(property: HttpProperty): boolean { + return getDefaultValue(property) !== undefined; +} + +export function getDefaultValue(property: ModelProperty): string | number | boolean | undefined; +export function getDefaultValue(property: HttpProperty): string | number | boolean | undefined; +export function getDefaultValue( + httpOrModelProperty: HttpProperty | ModelProperty, +): string | number | boolean | undefined { + let property; + + if ("kind" in httpOrModelProperty && httpOrModelProperty.kind === "ModelProperty") { + property = httpOrModelProperty; + } else { + property = httpOrModelProperty.property; + } + + if (property.defaultValue) { + if ("value" in property.defaultValue) { + return getValue(property.defaultValue); + } + } + + if ("value" in property.type && property.type.value !== undefined) { + return JSON.stringify(property.type.value); + } + + return undefined; +} + +function getValue(value: Value | undefined) { + if (!value) { + return undefined; + } + switch (value.valueKind) { + case "StringValue": + return `"${value.value}"`; + case "NumericValue": + return value.value.asNumber() ?? undefined; + case "BooleanValue": + return value.value; + default: + return undefined; + } +} diff --git a/packages/http-client-js/test/e2e/assets/image.jpg b/packages/http-client-js/test/e2e/assets/image.jpg new file mode 100644 index 00000000000..b95b3e7b582 Binary files /dev/null and b/packages/http-client-js/test/e2e/assets/image.jpg differ diff --git a/packages/http-client-js/test/e2e/assets/image.png b/packages/http-client-js/test/e2e/assets/image.png new file mode 100644 index 00000000000..42fe8dc1456 Binary files /dev/null and b/packages/http-client-js/test/e2e/assets/image.png differ diff --git a/packages/http-client-js/test/e2e/http/authentication/api-key/main.test.ts b/packages/http-client-js/test/e2e/http/authentication/api-key/main.test.ts new file mode 100644 index 00000000000..51c15f3c646 --- /dev/null +++ b/packages/http-client-js/test/e2e/http/authentication/api-key/main.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { ApiKeyClient } from "../../../generated/authentication/api-key/src/index.js"; + +describe("Authentication.ApiKey", () => { + const client = new ApiKeyClient( + { + key: "valid-key", // Set the default API key here + }, + { allowInsecureConnection: true }, + ); + + it("should authenticate with a valid API key", async () => { + const response = await client.valid(); + expect(response).toBeUndefined(); // NoContentResponse is expected + }); + + it.skip("should return error for an invalid API key", async () => { + const invalidClient = new ApiKeyClient( + { + key: "invalid-key", + }, + { allowInsecureConnection: true }, + ); + + try { + await invalidClient.invalid(); + throw new Error("Expected an error for invalid API key"); + } catch (error: any) { + expect(error.statusCode).toBe(403); + expect(error.body).toEqual({ + error: { + code: "InvalidApiKey", + message: "API key is invalid", + }, + }); + } + }); +}); diff --git a/packages/http-client-js/test/e2e/http/authentication/oauth2/main.test.ts b/packages/http-client-js/test/e2e/http/authentication/oauth2/main.test.ts new file mode 100644 index 00000000000..88dc50067ff --- /dev/null +++ b/packages/http-client-js/test/e2e/http/authentication/oauth2/main.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { OAuth2Client } from "../../../generated/authentication/oauth2/src/index.js"; + +describe("Authentication.OAuth2", () => { + const client = new OAuth2Client( + { + getToken: async () => ({ + token: "Bearer https://security.microsoft.com/.default", + expiresOnTimestamp: Date.now() + 3600 * 1000, + }), + }, + { allowInsecureConnection: true }, + ); + + it.skip("should validate the client is authenticated", async () => { + const response = await client.valid(); + expect(response).toBe(undefined); // No content response + }); + + it.skip("should handle invalid authentication and return error", async () => { + try { + const client = new OAuth2Client( + { + getToken: async () => ({ + token: "invalid", + expiresOnTimestamp: Date.now() + 3600 * 1000, + }), + }, + { allowInsecureConnection: true }, + ); + await client.invalid(); + } catch (error: any) { + expect(error.statusCode).toBe(403); + expect(error.error).toMatchObject({ + message: "Expected Bearer x but got Bearer y", + expected: "Bearer x", + actual: "Bearer y", + }); + } + }); +}); diff --git a/packages/http-client-js/test/e2e/http/authentication/union/main.test.ts b/packages/http-client-js/test/e2e/http/authentication/union/main.test.ts new file mode 100644 index 00000000000..ee53afc9d35 --- /dev/null +++ b/packages/http-client-js/test/e2e/http/authentication/union/main.test.ts @@ -0,0 +1,29 @@ +import { describe, it } from "vitest"; +import { UnionClient } from "../../../generated/authentication/union/src/index.js"; + +describe("Authentication.Union", () => { + it("should authenticate using the valid API key", async () => { + const client = new UnionClient( + { + key: "valid-key", + }, + { allowInsecureConnection: true }, + ); + await client.validKey(); + // Assert successful request + }); + + it.skip("should authenticate using the valid OAuth token", async () => { + const client = new UnionClient( + { + getToken: async () => ({ + token: "Bearer https://security.microsoft.com/.default", + expiresOnTimestamp: Date.now() + 3600 * 1000, + }), + }, + { allowInsecureConnection: true }, + ); + await client.validToken(); + // Assert successful request + }); +}); diff --git a/packages/http-client-js/test/e2e/http/encode/bytes/main.test.ts b/packages/http-client-js/test/e2e/http/encode/bytes/main.test.ts new file mode 100644 index 00000000000..ee703fbeefb --- /dev/null +++ b/packages/http-client-js/test/e2e/http/encode/bytes/main.test.ts @@ -0,0 +1,191 @@ +import { readFile } from "fs/promises"; +import { dirname, resolve } from "path"; +import { fileURLToPath } from "url"; +import { describe, expect, it } from "vitest"; +import { + HeaderClient, + PropertyClient, + QueryClient, + RequestBodyClient, + ResponseBodyClient, +} from "../../../generated/encode/bytes/src/index.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const pngImagePath = resolve( + __dirname, + "../../../../../node_modules/@typespec/http-specs/assets/image.png", +); +const pngBuffer = await readFile(pngImagePath); +const pngContents = new Uint8Array(pngBuffer); + +const base64EncodeToUint8Array = (input: string): Uint8Array => { + // Encode the string as Base64 + const base64String = btoa(input); + + // Decode Base64 into a binary string + const binaryString = atob(base64String); + + // Convert the binary string to a Uint8Array + const uint8Array = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + uint8Array[i] = binaryString.charCodeAt(i); + } + + return uint8Array; +}; + +const encodedTestString = base64EncodeToUint8Array("test"); + +const str = "test"; +const testUint8Array = new Uint8Array([...str].map((char) => char.charCodeAt(0))); + +describe("Encode.Bytes", () => { + describe("QueryClient", () => { + const client = new QueryClient({ allowInsecureConnection: true }); + + it.skip("should test default encode (base64) for bytes query parameter", async () => { + // + await client.default_(testUint8Array); + // Assert successful request + }); + + it("should test base64 encode for bytes query parameter", async () => { + await client.base64(testUint8Array); + // Assert successful request + }); + + it("should test base64url encode for bytes query parameter", async () => { + await client.base64url(testUint8Array); + // Assert successful request + }); + + it("should test base64url encode for bytes array query parameter", async () => { + await client.base64urlArray([testUint8Array, testUint8Array]); + // Assert successful request + }); + }); + + describe("PropertyClient", () => { + const client = new PropertyClient({ allowInsecureConnection: true }); + + it("should test default encode (base64) for bytes properties", async () => { + const response = await client.default_({ value: encodedTestString }); + expect(response.value).toStrictEqual(encodedTestString); + }); + + it("should test base64 encode for bytes properties", async () => { + const response = await client.base64({ value: testUint8Array }); + expect(response.value).toStrictEqual(encodedTestString); + }); + + it("should test base64url encode for bytes properties", async () => { + const response = await client.base64url({ value: testUint8Array }); + expect(response.value).toStrictEqual(encodedTestString); + }); + + it("should test base64url encode for bytes array properties", async () => { + const response = await client.base64urlArray({ + value: [testUint8Array, testUint8Array], + }); + expect(response.value).toStrictEqual([testUint8Array, testUint8Array]); + }); + }); + + describe("HeaderClient", () => { + const client = new HeaderClient({ allowInsecureConnection: true }); + + it("should test default encode (base64) for bytes header", async () => { + await client.default_(testUint8Array); + // Assert successful request + }); + + it("should test base64 encode for bytes header", async () => { + await client.base64(testUint8Array); + // Assert successful request + }); + + it("should test base64url encode for bytes header", async () => { + await client.base64url(testUint8Array); + // Assert successful request + }); + + it("should test base64url encode for bytes array header", async () => { + await client.base64urlArray([encodedTestString, encodedTestString]); + // Assert successful request + }); + }); + + describe("RequestBodyClient", () => { + const client = new RequestBodyClient({ + allowInsecureConnection: true, + retryOptions: { + maxRetries: 1, + }, + }); + + it.skip("should test default encode (base64) for bytes in JSON body", async () => { + await client.default_(encodedTestString); + // Assert successful request + }); + + it("should test application/octet-stream content type with bytes payload", async () => { + const content = pngContents; + await client.octetStream(content); + // Assert successful request + }); + + it("should test custom content type (image/png) with bytes payload", async () => { + const content = pngContents; + await client.customContentType(content); + // Assert successful request + }); + + it.skip("should test base64 encode for bytes body", async () => { + await client.base64(testUint8Array); + // Assert successful request + }); + + it.skip("should test base64url encode for bytes body", async () => { + await client.base64url(testUint8Array); + // Assert successful request + }); + }); + + describe("ResponseBodyClient", () => { + const client = new ResponseBodyClient({ + allowInsecureConnection: true, + retryOptions: { + maxRetries: 1, + }, + }); + + it("should test default encode (base64) for bytes in JSON body response", async () => { + const response = await client.default_(); + expect(response).toStrictEqual(encodedTestString); + }); + + it.skip("should test application/octet-stream content type with bytes response", async () => { + const response = await client.octetStream(); + const expectedContent = pngContents; + expect(response).toStrictEqual(expectedContent); + }); + + it.skip("should test custom content type (image/png) with bytes response", async () => { + const response = await client.customContentType(); + const expectedContent = pngContents; + expect(response).toStrictEqual(expectedContent); + }); + + it("should test base64 encode for bytes response body", async () => { + const response = await client.base64(); + expect(response).toStrictEqual(encodedTestString); + }); + + it("should test base64url encode for bytes response body", async () => { + const response = await client.base64url(); + expect(response).toStrictEqual(encodedTestString); + }); + }); +}); diff --git a/packages/http-client-js/test/e2e/http/encode/datetime/main.test.ts b/packages/http-client-js/test/e2e/http/encode/datetime/main.test.ts new file mode 100644 index 00000000000..962299fc963 --- /dev/null +++ b/packages/http-client-js/test/e2e/http/encode/datetime/main.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, it } from "vitest"; +import { + HeaderClient, + PropertyClient, + QueryClient, + ResponseHeaderClient, +} from "../../../generated/encode/datetime/src/index.js"; + +describe("Encode.Datetime", () => { + describe("QueryClient", () => { + const client = new QueryClient({ allowInsecureConnection: true }); + + it("should test default encode (rfc3339) for datetime query parameter", async () => { + await client.default_(new Date("2022-08-26T18:38:00.000Z")); + // Assert successful request + }); + + it("should test rfc3339 encode for datetime query parameter", async () => { + await client.rfc3339(new Date("2022-08-26T18:38:00.000Z")); + // Assert successful request + }); + + it("should test rfc7231 encode for datetime query parameter", async () => { + await client.rfc7231(new Date("2022-08-26T14:38:00.000Z")); + // Assert successful request + }); + + it("should test unixTimestamp encode for datetime query parameter", async () => { + // For QueryClient, the correct unixTimestamp date is unchanged. + await client.unixTimestamp(new Date("2023-06-12T10:47:44Z")); + // Assert successful request. + }); + + it("should test unixTimestamp encode for datetime array query parameter", async () => { + // For QueryClient, the array dates remain as before. + const timestamps = [new Date("2023-06-12T10:47:44Z"), new Date("2023-06-14T09:17:36Z")]; + await client.unixTimestampArray(timestamps); + // Assert successful request + }); + }); + + describe("PropertyClient", () => { + const client = new PropertyClient({ allowInsecureConnection: true }); + + it("should handle default encode (rfc3339) for datetime property", async () => { + const requestBody = { value: new Date("2022-08-26T18:38:00.000Z") }; + const response = await client.default_(requestBody); + expect(response).toEqual(requestBody); + }); + + it("should handle rfc3339 encode for datetime property", async () => { + const requestBody = { value: new Date("2022-08-26T18:38:00.000Z") }; + const response = await client.rfc3339(requestBody); + expect(response).toEqual(requestBody); + }); + + it("should handle rfc7231 encode for datetime property", async () => { + // Use a fixed date string for clarity. + const requestBody = { value: new Date("Fri, 26 Aug 2022 14:38:00 GMT") }; + const response = await client.rfc7231(requestBody); + expect(response).toEqual(requestBody); + }); + + it.skip("should handle unixTimestamp encode for datetime property", async () => { + // Correct property unixTimestamp date. + const requestBody = { value: new Date("2023-06-12T06:41:04.000Z") }; + const response = await client.unixTimestamp(requestBody); + expect(response).toEqual(requestBody); + }); + + it.skip("should handle unixTimestamp encode for datetime array property", async () => { + // Correct property unixTimestamp-array dates. + const requestBody = { + value: [new Date("2023-06-12T06:41:04.000Z"), new Date("2023-06-14T11:57:36.000Z")], + }; + const response = await client.unixTimestampArray(requestBody); + expect(response).toEqual(requestBody); + }); + }); + + describe("HeaderClient", () => { + const client = new HeaderClient({ allowInsecureConnection: true }); + + it("should test default encode (rfc7231) for datetime header", async () => { + await client.default_(new Date("2022-08-26T14:38:00.000Z")); + // Assert successful request + }); + + it("should test rfc3339 encode for datetime header", async () => { + await client.rfc3339(new Date("2022-08-26T18:38:00.000Z")); + // Assert successful request + }); + + it("should test rfc7231 encode for datetime header", async () => { + await client.rfc7231(new Date("2022-08-26T14:38:00.000Z")); + // Assert successful request + }); + + it.skip("should test unixTimestamp encode for datetime header", async () => { + // Correct should test default encode (base64) for bytes query parameterheader unixTimestamp date. + await client.unixTimestamp(new Date("2023-06-12T06:41:04.000Z")); + // Assert successful request + }); + + it.skip("should test unixTimestamp encode for datetime array header", async () => { + // Correct header unixTimestamp-array dates. + const timestamps = [ + new Date("2023-06-12T06:41:04.000Z"), + new Date("2023-06-14T11:57:36.000Z"), + ]; + await client.unixTimestampArray(timestamps); + // Assert successful request + }); + }); + + describe("ResponseHeaderClient", () => { + const client = new ResponseHeaderClient({ + allowInsecureConnection: true, + retryOptions: { + maxRetries: 1, + }, + }); + + it("should handle default encode (rfc7231) for datetime response header", async () => { + let value; + await client.default_({ + operationOptions: { + onResponse: (r) => { + value = new Date((r.headers as any)["value"]); + }, + }, + }); + expect(value).toEqual(new Date("2022-08-26T14:38:00.000Z")); + }); + + it("should handle rfc3339 encode for datetime response header", async () => { + let value; + + await client.rfc3339({ + operationOptions: { + onResponse: (r) => { + value = new Date((r.headers as any)["value"]); + }, + }, + }); + expect(value).toEqual(new Date("2022-08-26T18:38:00.000Z")); + }); + + it("should handle rfc7231 encode for datetime response header", async () => { + let value; + + await client.rfc3339({ + operationOptions: { + onResponse: (r) => { + value = new Date((r.headers as any)["value"]); + }, + }, + }); + expect(value).toEqual(new Date("2022-08-26T18:38:00.000Z")); + }); + + it("should handle unixTimestamp encode for datetime response header", async () => { + let value; + + // Correct response header unixTimestamp date. + await client.rfc3339({ + operationOptions: { + onResponse: (r) => { + value = r.headers["value"]; + }, + }, + }); + expect(value).toEqual("2022-08-26T18:38:00.000Z"); + }); + }); +}); diff --git a/packages/http-client-js/test/e2e/http/encode/duration/main.test.ts b/packages/http-client-js/test/e2e/http/encode/duration/main.test.ts new file mode 100644 index 00000000000..5b85cfeab7e --- /dev/null +++ b/packages/http-client-js/test/e2e/http/encode/duration/main.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from "vitest"; +import { + HeaderClient, + PropertyClient, + QueryClient, +} from "../../../generated/encode/duration/src/index.js"; + +describe("Encode.Duration", () => { + describe("QueryClient", () => { + const queryClient = new QueryClient({ allowInsecureConnection: true }); + + it("should test default encode for a duration parameter", async () => { + await queryClient.default_("P40D"); + // Assert successful request + }); + + it("should test iso8601 encode for a duration parameter", async () => { + await queryClient.iso8601("P40D"); + // Assert successful request + }); + + it.skip("should test int32 seconds encode for a duration parameter", async () => { + await queryClient.int32Seconds("P40D"); + // Assert successful request + }); + + it.skip("should test float seconds encode for a duration parameter", async () => { + await queryClient.floatSeconds("PT35.625S"); + // Assert successful request + }); + + it.skip("should test float64 seconds encode for a duration parameter", async () => { + await queryClient.float64Seconds("PT35.625S"); + // Assert successful request + }); + + it.skip("should test int32 seconds encode for a duration array parameter", async () => { + await queryClient.int32SecondsArray(["P36D", "P47D"]); + // Assert successful request + }); + }); + + describe("PropertyClient", () => { + const propertyClient = new PropertyClient({ + allowInsecureConnection: true, + retryOptions: { + maxRetries: 1, + }, + }); + + it("should test default encode for a duration property", async () => { + const requestBody = { value: "P40D" }; + const response = await propertyClient.default_(requestBody); + expect(response).toEqual({ value: "P40D" }); + }); + + it("should test iso8601 encode for a duration property", async () => { + const requestBody = { value: "P40D" }; + const response = await propertyClient.iso8601(requestBody); + expect(response).toEqual({ value: "P40D" }); + }); + + it.skip("should test int32 seconds encode for a duration property", async () => { + const requestBody = { value: "PT36S" }; + const response = await propertyClient.int32Seconds(requestBody); + expect(response).toEqual({ value: 36 }); + }); + + it.skip("should test float seconds encode for a duration property", async () => { + const requestBody = { value: "PT35.625S" }; + const response = await propertyClient.floatSeconds(requestBody); + expect(response).toEqual({ value: 35.625 }); + }); + + it.skip("should test float64 seconds encode for a duration property", async () => { + const requestBody = { value: "PT35.625S" }; + const response = await propertyClient.float64Seconds(requestBody); + expect(response).toEqual({ value: 35.625 }); + }); + + it.skip("should test float seconds encode for a duration array property", async () => { + const requestBody = { value: ["PT35.625S", "PT46.75S"] }; + const response = await propertyClient.floatSecondsArray(requestBody); + expect(response).toEqual({ value: [35.625, 46.75] }); + }); + }); + + describe("HeaderClient", () => { + const headerClient = new HeaderClient({ + allowInsecureConnection: true, + retryOptions: { + maxRetries: 1, + }, + }); + + it("should test default encode for a duration header", async () => { + await headerClient.default_("P40D"); + // Assert successful request + }); + + it("should test iso8601 encode for a duration header", async () => { + await headerClient.iso8601("P40D"); + // Assert successful request + }); + + it("should test iso8601 encode for a duration array header", async () => { + await headerClient.iso8601Array(["P40D", "P50D"]); + // Assert successful request + }); + + it.skip("should test int32 seconds encode for a duration header", async () => { + await headerClient.int32Seconds("P40D"); + // Assert successful request + }); + + it.skip("should test float seconds encode for a duration header", async () => { + await headerClient.floatSeconds("PT35.625S"); + // Assert successful request + }); + + it.skip("should test float64 seconds encode for a duration header", async () => { + await headerClient.float64Seconds("PT35.625S"); + // Assert successful request + }); + }); +}); diff --git a/packages/http-client-js/test/e2e/http/encode/numeric/main.test.ts b/packages/http-client-js/test/e2e/http/encode/numeric/main.test.ts new file mode 100644 index 00000000000..2ea201bddd2 --- /dev/null +++ b/packages/http-client-js/test/e2e/http/encode/numeric/main.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { PropertyClient } from "../../../generated/encode/numeric/src/index.js"; + +describe("Encode.Numeric", () => { + describe("PropertyClient", () => { + const client = new PropertyClient({ allowInsecureConnection: true }); + + it.skip("should send and receive safeint as string", async () => { + const payload = { value: 10000000000 }; + const response = await client.safeintAsString(payload); + expect(response).toEqual(payload); // Mock API expected value + }); + + it.skip("should send and receive optional uint32 as string", async () => { + const payload = { value: 1 }; + const response = await client.uint32AsStringOptional(payload); + expect(response).toEqual(payload); // Mock API expected value + }); + + it.skip("should send and receive uint8 as string", async () => { + const payload = { value: 255 }; + const response = await client.uint8AsString(payload); + expect(response).toEqual(payload); // Mock API expected value + }); + }); +}); diff --git a/packages/http-client-js/test/e2e/http/parameters/basic/main.test.ts b/packages/http-client-js/test/e2e/http/parameters/basic/main.test.ts new file mode 100644 index 00000000000..d7ab6e21589 --- /dev/null +++ b/packages/http-client-js/test/e2e/http/parameters/basic/main.test.ts @@ -0,0 +1,35 @@ +import { describe, it } from "vitest"; +import { + ExplicitBodyClient, + ImplicitBodyClient, +} from "../../../generated/parameters/basic/src/index.js"; + +describe("Parameters.Basic", () => { + describe("ExplicitBodyClient", () => { + const client = new ExplicitBodyClient({ + allowInsecureConnection: true, + retryOptions: { + maxRetries: 1, + }, + }); + + it("should handle a simple explicit body", async () => { + await client.simple({ name: "foo" }); + // Assert successful request + }); + }); + + describe("ImplicitBodyClient", () => { + const client = new ImplicitBodyClient({ + allowInsecureConnection: true, + retryOptions: { + maxRetries: 1, + }, + }); + + it("should handle a simple implicit body", async () => { + await client.simple("foo"); + // Assert successful request + }); + }); +}); diff --git a/packages/http-client-js/test/e2e/http/parameters/body-optionality/main.test.ts b/packages/http-client-js/test/e2e/http/parameters/body-optionality/main.test.ts new file mode 100644 index 00000000000..48e16aa7384 --- /dev/null +++ b/packages/http-client-js/test/e2e/http/parameters/body-optionality/main.test.ts @@ -0,0 +1,43 @@ +import { describe, it } from "vitest"; +import { + BodyOptionalityClient, + OptionalExplicitClient, +} from "../../../generated/parameters/body-optionality/src/index.js"; + +describe("Parameters.BodyOptionality", () => { + const client = new BodyOptionalityClient({ + allowInsecureConnection: true, + retryOptions: { + maxRetries: 1, + }, + }); + + it("should handle required explicit body parameter", async () => { + await client.requiredExplicit({ name: "foo" }); + // Assert successful request + }); + + describe("OptionalExplicitClient", () => { + const optionalExplicitClient = new OptionalExplicitClient({ + allowInsecureConnection: true, + retryOptions: { + maxRetries: 1, + }, + }); + + it("should handle explicit optional body parameter (set case)", async () => { + await optionalExplicitClient.set({ body: { name: "foo" } }); + // Assert successful request + }); + + it("should handle explicit optional body parameter (omit case)", async () => { + await optionalExplicitClient.omit(); + // Assert successful request + }); + }); + + it("should handle implicit required body parameter", async () => { + await client.requiredImplicit("foo"); + // Assert successful request + }); +}); diff --git a/packages/http-client-js/test/e2e/http/parameters/collection-format/main.test.ts b/packages/http-client-js/test/e2e/http/parameters/collection-format/main.test.ts new file mode 100644 index 00000000000..837ad609ef7 --- /dev/null +++ b/packages/http-client-js/test/e2e/http/parameters/collection-format/main.test.ts @@ -0,0 +1,45 @@ +import { describe, it } from "vitest"; +import { + HeaderClient, + QueryClient, +} from "../../../generated/parameters/collection-format/src/index.js"; + +describe("Parameters.CollectionFormat", () => { + describe("QueryClient", () => { + const client = new QueryClient({ allowInsecureConnection: true }); + + it("should test sending a multi collection format array query parameters", async () => { + await client.multi(["blue", "red", "green"]); + // Assert successful request + }); + + it.skip("should test sending an ssv collection format array query parameters", async () => { + await client.ssv(["blue", "red", "green"]); + // Assert successful request + }); + + it.skip("should test sending a tsv collection format array query parameters", async () => { + await client.tsv(["blue", "red", "green"]); + // Assert successful request + }); + + it.skip("should test sending a pipes collection format array query parameters", async () => { + await client.pipes(["blue", "red", "green"]); + // Assert successful request + }); + + it("should test sending a csv collection format array query parameters", async () => { + await client.csv(["blue", "red", "green"]); + // Assert successful request + }); + }); + + describe("HeaderClient", () => { + const client = new HeaderClient({ allowInsecureConnection: true }); + + it("should test sending a csv collection format array header parameters", async () => { + await client.csv(["blue", "red", "green"]); + // Assert successful request + }); + }); +}); diff --git a/packages/http-client-js/test/e2e/http/parameters/spread/main.test.ts b/packages/http-client-js/test/e2e/http/parameters/spread/main.test.ts new file mode 100644 index 00000000000..956af09f1b1 --- /dev/null +++ b/packages/http-client-js/test/e2e/http/parameters/spread/main.test.ts @@ -0,0 +1,56 @@ +import { describe, it } from "vitest"; +import { AliasClient, ModelClient } from "../../../generated/parameters/spread/src/index.js"; + +describe("Parameters.Spread", () => { + describe("ModelClient", () => { + const client = new ModelClient({ allowInsecureConnection: true }); + + it("should handle spread named model in request body", async () => { + await client.spreadAsRequestBody("foo"); + }); + + it("should handle spread model with only @body property", async () => { + await client.spreadCompositeRequestOnlyWithBody({ name: "foo" }); + }); + + it("should handle spread model without @body property", async () => { + await client.spreadCompositeRequestWithoutBody("foo", "bar"); + }); + + it("should handle spread model with all HTTP request decorators", async () => { + await client.spreadCompositeRequest("foo", "bar", { name: "foo" }); + }); + + it("should handle spread model with non-body HTTP request decorators", async () => { + await client.spreadCompositeRequestMix("foo", "bar", "foo"); + }); + }); + + describe("AliasClient", () => { + const client = new AliasClient({ allowInsecureConnection: true }); + + it("should handle spread alias in request body", async () => { + await client.spreadAsRequestBody("foo"); + }); + + it("should handle spread alias with inner model in parameters", async () => { + await client.spreadParameterWithInnerModel("1", "foo", "bar"); + }); + + it("should handle spread alias with path and header parameters", async () => { + await client.spreadAsRequestParameter("1", "bar", "foo"); + }); + + it("should handle spread alias including multiple parameters, optional and required", async () => { + // Required parameters are positional and optional parameters are within the options bag + await client.spreadWithMultipleParameters("1", "bar", "foo", [1, 2], { + optionalInt: 1, + optionalStringList: ["foo", "bar"], + }); + }); + + it("should handle spread alias containing another alias property as body", async () => { + await client.spreadParameterWithInnerAlias("1", "foo", 1, "bar"); + }); + }); +}); diff --git a/packages/http-client-js/test/e2e/http/payload/content-negotiation/main.test.ts b/packages/http-client-js/test/e2e/http/payload/content-negotiation/main.test.ts new file mode 100644 index 00000000000..1217a2f86ba --- /dev/null +++ b/packages/http-client-js/test/e2e/http/payload/content-negotiation/main.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { + DifferentBodyClient, + SameBodyClient, +} from "../../../generated/payload/content-negotiation/src/index.js"; + +describe("Payload.ContentNegotiation", () => { + describe("SameBodyClient", () => { + const client = new SameBodyClient({ allowInsecureConnection: true }); + + it.skip("should return a PNG image when 'Accept: image/png' is sent", async () => { + const response = await client.getAvatarAsPng(); + expect(response).toBeInstanceOf(Uint8Array); // Mock API expected binary result + }); + + it.skip("should return a JPEG image when 'Accept: image/jpeg' is sent", async () => { + const response = await client.getAvatarAsJpeg(); + expect(response).toBeInstanceOf(Uint8Array); // Mock API expected binary result + }); + }); + + describe("DifferentBodyClient", () => { + const client = new DifferentBodyClient({ + allowInsecureConnection: true, + retryOptions: { + maxRetries: 1, + }, + }); + + it.skip("should return a PNG image when 'Accept: image/png' is sent", async () => { + const response = await client.getAvatarAsPng(); + expect(response).toBeInstanceOf(Uint8Array); // Mock API expected binary result + }); + + it.skip("should return a JSON object containing a PNG image when 'Accept: application/json' is sent", async () => { + const response = await client.getAvatarAsJson(); + expect(response.content).toBeInstanceOf(Uint8Array); // Mock API expected binary result + }); + }); +}); diff --git a/packages/http-client-js/test/e2e/http/payload/json-merge-patch/main.test.ts b/packages/http-client-js/test/e2e/http/payload/json-merge-patch/main.test.ts new file mode 100644 index 00000000000..4dc83399fd8 --- /dev/null +++ b/packages/http-client-js/test/e2e/http/payload/json-merge-patch/main.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from "vitest"; +import { JsonMergePatchClient } from "../../../generated/payload/json-merge-patch/src/index.js"; + +describe("Payload.JsonMergePatch", () => { + const client = new JsonMergePatchClient({ + allowInsecureConnection: true, + retryOptions: { + maxRetries: 1, + }, + }); + + it("should handle createResource operation with application/merge-patch+json content type", async () => { + const requestBody = { + name: "Madge", + description: "desc", + map: { + key: { + name: "InnerMadge", + description: "innerDesc", + }, + }, + array: [ + { + name: "InnerMadge", + description: "innerDesc", + }, + ], + intValue: 1, + floatValue: 1.1, + innerModel: { + name: "InnerMadge", + description: "innerDesc", + }, + intArray: [1, 2, 3], + }; + + const expectedResponse = { + name: "Madge", + description: "desc", + map: { + key: { + name: "InnerMadge", + description: "innerDesc", + }, + }, + array: [ + { + name: "InnerMadge", + description: "innerDesc", + }, + ], + intValue: 1, + floatValue: 1.1, + innerModel: { + name: "InnerMadge", + description: "innerDesc", + }, + intArray: [1, 2, 3], + }; + + const response = await client.createResource(requestBody); + expect(response).toEqual(expectedResponse); + }); + + it("should handle updateResource operation with application/merge-patch+json content type", async () => { + const requestBody = { + description: null, + map: { + key: { + description: null, + }, + key2: null, + }, + array: null, + intValue: null, + floatValue: null, + innerModel: null, + intArray: null, + }; + + const expectedResponse = { + name: "Madge", + map: { + key: { + name: "InnerMadge", + }, + }, + }; + + const response = await client.updateResource(requestBody as any); + expect(response).toEqual(expectedResponse); + }); + + it.skip("should handle updateOptionalResource operation with application/merge-patch+json content type and body provided", async () => { + const requestBody = { + description: null, + map: { + key: { + description: null, + }, + key2: null, + }, + array: null, + intValue: null, + floatValue: null, + innerModel: null, + intArray: null, + }; + + const expectedResponse = { + name: "Madge", + map: { + key: { + name: "InnerMadge", + }, + }, + }; + + const response = await client.updateOptionalResource(requestBody as any); + expect(response).toEqual(expectedResponse); + }); +}); diff --git a/packages/http-client-js/test/e2e/http/payload/media-type/main.test.ts b/packages/http-client-js/test/e2e/http/payload/media-type/main.test.ts new file mode 100644 index 00000000000..ce51a82412a --- /dev/null +++ b/packages/http-client-js/test/e2e/http/payload/media-type/main.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { StringBodyClient } from "../../../generated/payload/media-type/src/index.js"; + +describe("Payload.MediaType", () => { + describe("StringBodyClient", () => { + const client = new StringBodyClient({ allowInsecureConnection: true }); + + it.skip("should send a string body as text/plain", async () => { + await client.sendAsText("cat"); + // Assert successful request + }); + + it.skip("should get a string body as text/plain", async () => { + const response = await client.getAsText(); + expect(response).toBe("cat"); // Mock API expected value + }); + + it("should send a string body as application/json", async () => { + await client.sendAsJson("foo"); + // Assert successful request + }); + + it("should get a string body as application/json", async () => { + const response = await client.getAsJson(); + expect(response).toBe("foo"); // Mock API expected value + }); + }); +}); diff --git a/packages/http-client-js/test/e2e/http/payload/multipart/main.test.ts b/packages/http-client-js/test/e2e/http/payload/multipart/main.test.ts new file mode 100644 index 00000000000..fa6cb5af37b --- /dev/null +++ b/packages/http-client-js/test/e2e/http/payload/multipart/main.test.ts @@ -0,0 +1,164 @@ +import { readFile } from "fs/promises"; +import { dirname, resolve } from "path"; +import { fileURLToPath } from "url"; +import { beforeEach, describe, it } from "vitest"; +import { FormDataClient, HttpPartsClient } from "../../../generated/payload/multipart/src/index.js"; + +describe("Payload.MultiPart", () => { + let __filename; + let __dirname; + let jpegContents: Uint8Array; + let pngContents: Uint8Array; + + describe.skip("FormDataClient", () => { + const client = new FormDataClient({ + allowInsecureConnection: true, + retryOptions: { maxRetries: 1 }, + }); + + beforeEach(async () => { + __filename = fileURLToPath(import.meta.url); + __dirname = dirname(__filename); + + const jpegImagePath = resolve(__dirname, "../../../assets/image.jpg"); + const jpegBuffer = await readFile(jpegImagePath); + jpegContents = new Uint8Array(jpegBuffer); + + const pngImagePath = resolve(__dirname, "../../../assets/image.png"); + const pngBuffer = await readFile(pngImagePath); + pngContents = new Uint8Array(pngBuffer); + }); + + it("should send mixed parts with multipart/form-data", async () => { + await client.basic({ + id: "123", + profileImage: jpegContents, + }); + }); + + it("should send complex parts with multipart/form-data", async () => { + const address = { city: "X" }; + await client.fileArrayAndBasic({ + id: "123", + address, + profileImage: jpegContents, + pictures: [pngContents, pngContents], + }); + }); + + it("should send json part with binary part", async () => { + const address = { city: "X" }; + await client.jsonPart({ + address, + profileImage: new Uint8Array([ + /* file content */ + ]), + }); + }); + + it("should send binary array parts with multipart/form-data", async () => { + await client.binaryArrayParts({ + id: "123", + pictures: [pngContents, pngContents], + }); + }); + + it("should send multi-binary parts multiple times", async () => { + await client.multiBinaryParts({ + profileImage: jpegContents, + picture: pngContents, + }); + }); + + it("should send parts and check filename/content-type", async () => { + await client.checkFileNameAndContentType({ + id: "123", + profileImage: jpegContents, + }); + }); + + it("should send anonymous model with multipart/form-data", async () => { + await client.anonymousModel(jpegContents); + }); + }); + + describe.skip("FormDataClient.HttpParts.ContentType", () => { + const client = new HttpPartsClient({ + allowInsecureConnection: true, + retryOptions: { maxRetries: 1 }, + }); + + it("should handle image/jpeg with specific content type", async () => { + await client.contentTypeClient.imageJpegContentType({ + profileImage: { + contents: jpegContents, + contentType: "image/jpg", + filename: "image.jpg", + }, + }); + }); + + it("should handle required content type with multipart/form-data", async () => { + await client.contentTypeClient.requiredContentType({ + profileImage: { + contents: jpegContents, + contentType: "image/jpg", + filename: "image.jpg", + }, + }); + }); + + it("should handle optional content type file parts", async () => { + await client.contentTypeClient.optionalContentType({ + profileImage: { + contents: jpegContents, + filename: "image.jpg", + }, + }); + }); + }); + + describe("FormDataClient.HttpParts", () => { + it.skip("should send json array and file array", async () => { + const client = new HttpPartsClient({ + allowInsecureConnection: true, + retryOptions: { + maxRetries: 1, + }, + }); + const address = { city: "X" }; + const previousAddresses = [{ city: "Y" }, { city: "Z" }]; + await client.jsonArrayAndFileArray({ + id: "123", + address, + profileImage: { + contents: jpegContents, + contentType: "application/octet-stream", + filename: "profile.jpg", + }, + previousAddresses, + pictures: [ + { contents: pngContents, contentType: "application/octet-stream", filename: "pic1.png" }, + { contents: pngContents, contentType: "application/octet-stream", filename: "pic2.png" }, + ], + }); + }); + }); + + describe("FormDataClient.HttpParts.NonString", () => { + it.skip("should handle non-string float", async () => { + const client = new HttpPartsClient({ + allowInsecureConnection: true, + retryOptions: { + maxRetries: 1, + }, + }); + await client.nonStringClient.float({ + temperature: { + body: 0.5, + contentType: "text/plain", + }, + }); + }); + }); +}); diff --git a/packages/http-client-js/test/e2e/http/payload/pageable/main.test.ts b/packages/http-client-js/test/e2e/http/payload/pageable/main.test.ts new file mode 100644 index 00000000000..cb83a87c91c --- /dev/null +++ b/packages/http-client-js/test/e2e/http/payload/pageable/main.test.ts @@ -0,0 +1,24 @@ +/* eslint-disable vitest/no-commented-out-tests */ +import { describe } from "vitest"; +// import { ServerDrivenPaginationClient } from "../../../generated/payload/pageable/src/index.js"; + +describe.skip("Payload.Pageable", () => { + // describe("list", () => { + // const client = new ServerDrivenPaginationClient({ + // allowInsecureConnection: true, + // }); + // it("should list users with pagination", async () => { + // const users = client.list({ + // maxpagesize: 3, + // }); + // const firstPage = await users.next(); + // expect(firstPage.value).toEqual([{ name: "user5" }, { name: "user6" }, { name: "user7" }]); + // expect(firstPage.nextLink).toBe( + // "http://localhost:3000/payload/pageable?skipToken=name-user7&maxpagesize=3", + // ); + // const secondPage = await users.next(); + // expect(secondPage.value).toEqual([{ name: "user8" }]); + // expect(secondPage.nextLink).toBeUndefined(); + // }); + // }); +}); diff --git a/packages/http-client-js/test/e2e/http/payload/xml/main.test.ts b/packages/http-client-js/test/e2e/http/payload/xml/main.test.ts new file mode 100644 index 00000000000..fd5ea367772 --- /dev/null +++ b/packages/http-client-js/test/e2e/http/payload/xml/main.test.ts @@ -0,0 +1,287 @@ +import { describe, expect, it } from "vitest"; +import { + ModelWithArrayOfModelValueClient, + ModelWithAttributesValueClient, + ModelWithDictionaryValueClient, + ModelWithEmptyArrayValueClient, + ModelWithEncodedNamesValueClient, + ModelWithOptionalFieldValueClient, + ModelWithRenamedArraysValueClient, + ModelWithRenamedFieldsValueClient, + ModelWithSimpleArraysValueClient, + ModelWithTextValueClient, + ModelWithUnwrappedArrayValueClient, + SimpleModelValueClient, +} from "../../../generated/payload/xml/src/index.js"; + +describe.skip("Payload.Xml", () => { + describe("SimpleModelValueClient", () => { + const client = new SimpleModelValueClient({ + allowInsecureConnection: true, + retryOptions: { + maxRetries: 1, + }, + }); + + it("should handle a SimpleModel value returned from the server", async () => { + const response = await client.get(); + expect(response).toEqual({ name: "foo", age: 123 }); + }); + + it("should send a SimpleModel value to the server", async () => { + await client.put({ name: "foo", age: 123 }); + }); + }); + + describe("ModelWithSimpleArraysValueClient", () => { + const client = new ModelWithSimpleArraysValueClient({ + allowInsecureConnection: true, + retryOptions: { + maxRetries: 1, + }, + }); + + it("should handle a ModelWithSimpleArrays value returned from the server", async () => { + const response = await client.get(); + expect(response).toEqual({ + colors: ["red", "green", "blue"], + counts: [1, 2], + }); + }); + + it("should send a ModelWithSimpleArrays value to the server", async () => { + await client.put({ + colors: ["red", "green", "blue"], + counts: [1, 2], + }); + }); + }); + + describe("ModelWithArrayOfModelValueClient", () => { + const client = new ModelWithArrayOfModelValueClient({ + allowInsecureConnection: true, + retryOptions: { + maxRetries: 1, + }, + }); + + it("should handle a ModelWithArrayOfModel value returned from the server", async () => { + const response = await client.get(); + expect(response).toEqual({ + items: [ + { name: "foo", age: 123 }, + { name: "bar", age: 456 }, + ], + }); + }); + + it("should send a ModelWithArrayOfModel value to the server", async () => { + await client.put({ + items: [ + { name: "foo", age: 123 }, + { name: "bar", age: 456 }, + ], + }); + }); + }); + + describe("ModelWithOptionalFieldValueClient", () => { + const client = new ModelWithOptionalFieldValueClient({ + allowInsecureConnection: true, + retryOptions: { + maxRetries: 1, + }, + }); + + it("should handle a ModelWithOptionalField value returned from the server", async () => { + const response = await client.get(); + expect(response).toEqual({ item: "widget" }); + }); + + it("should send a ModelWithOptionalField value to the server", async () => { + await client.put({ item: "widget" }); + }); + }); + + describe("ModelWithAttributesValueClient", () => { + const client = new ModelWithAttributesValueClient({ + allowInsecureConnection: true, + retryOptions: { + maxRetries: 1, + }, + }); + + it("should handle a ModelWithAttributes value returned from the server", async () => { + const response = await client.get(); + expect(response).toEqual({ id1: 123, id2: "foo", enabled: true }); + }); + + it("should send a ModelWithAttributes value to the server", async () => { + await client.put({ + id1: 123, + id2: "foo", + enabled: true, + }); + }); + }); + + describe("ModelWithUnwrappedArrayValueClient", () => { + const client = new ModelWithUnwrappedArrayValueClient({ + allowInsecureConnection: true, + retryOptions: { + maxRetries: 1, + }, + }); + + it("should handle a ModelWithUnwrappedArray value returned from the server", async () => { + const response = await client.get(); + expect(response).toEqual({ + colors: ["red", "green", "blue"], + counts: [1, 2], + }); + }); + + it("should send a ModelWithUnwrappedArray value to the server", async () => { + await client.put({ + colors: ["red", "green", "blue"], + counts: [1, 2], + }); + }); + }); + + describe("ModelWithRenamedArraysValueClient", () => { + const client = new ModelWithRenamedArraysValueClient({ + allowInsecureConnection: true, + retryOptions: { + maxRetries: 1, + }, + }); + + it("should handle a ModelWithRenamedArrays value returned from the server", async () => { + const response = await client.get(); + expect(response).toEqual({ + colors: ["red", "green", "blue"], + counts: [1, 2], + }); + }); + + it("should send a ModelWithRenamedArrays value to the server", async () => { + await client.put({ + colors: ["red", "green", "blue"], + counts: [1, 2], + }); + }); + }); + + describe("ModelWithRenamedFieldsValueClient", () => { + const client = new ModelWithRenamedFieldsValueClient({ + allowInsecureConnection: true, + retryOptions: { + maxRetries: 1, + }, + }); + + it("should handle a ModelWithRenamedFields value returned from the server", async () => { + const response = await client.get(); + expect(response).toEqual({ + inputData: { name: "foo", age: 123 }, + outputData: { name: "bar", age: 456 }, + }); + }); + + it("should send a ModelWithRenamedFields value to the server", async () => { + await client.put({ + inputData: { name: "foo", age: 123 }, + outputData: { name: "bar", age: 456 }, + }); + }); + }); + + describe("ModelWithEmptyArrayValueClient", () => { + const client = new ModelWithEmptyArrayValueClient({ + allowInsecureConnection: true, + retryOptions: { + maxRetries: 1, + }, + }); + + it("should handle a ModelWithEmptyArray value returned from the server", async () => { + const response = await client.get(); + expect(response).toEqual({ items: [] }); + }); + + it("should send a ModelWithEmptyArray value to the server", async () => { + await client.put({ items: [] }); + }); + }); + + describe("ModelWithTextValueClient", () => { + const client = new ModelWithTextValueClient({ + allowInsecureConnection: true, + retryOptions: { + maxRetries: 1, + }, + }); + + it("should handle a ModelWithText value returned from the server", async () => { + const response = await client.get(); + expect(response).toEqual({ + language: "foo", + content: "This is some text.", + }); + }); + + it("should send a ModelWithText value to the server", async () => { + await client.put({ + language: "foo", + content: "This is some text.", + }); + }); + }); + + describe("ModelWithDictionaryValueClient", () => { + const client = new ModelWithDictionaryValueClient({ + allowInsecureConnection: true, + retryOptions: { + maxRetries: 1, + }, + }); + + it("should handle a ModelWithDictionary value returned from the server", async () => { + const response = await client.get(); + expect(response).toEqual({ + metadata: { Color: "blue", Count: 123, Enabled: false }, + }); + }); + + it("should send a ModelWithDictionary value to the server", async () => { + await client.put({ + metadata: { Color: "blue", Count: 123, Enabled: false } as any, + }); + }); + }); + + describe("ModelWithEncodedNamesValueClient", () => { + const client = new ModelWithEncodedNamesValueClient({ + allowInsecureConnection: true, + retryOptions: { + maxRetries: 1, + }, + }); + + it("should handle a ModelWithEncodedNames value returned from the server", async () => { + const response = await client.get(); + expect(response).toEqual({ + modelData: { name: "foo", age: 123 }, + colors: ["red", "green", "blue"], + }); + }); + + it("should send a ModelWithEncodedNames value to the server", async () => { + await client.put({ + modelData: { name: "foo", age: 123 }, + colors: ["red", "green", "blue"], + }); + }); + }); +}); diff --git a/packages/http-client-js/test/e2e/http/routes/main.test.ts b/packages/http-client-js/test/e2e/http/routes/main.test.ts new file mode 100644 index 00000000000..4b26e8c409f --- /dev/null +++ b/packages/http-client-js/test/e2e/http/routes/main.test.ts @@ -0,0 +1,284 @@ +import { describe, it } from "vitest"; +import { + ExplodeClient as ContinuationExplodeClient, + StandardClient as ContinuationStandardClient, + InInterfaceClient, + ExplodeClient as LabelExplodeClient, + StandardClient as LabelStandardClient, + ExplodeClient as MatrixExplodeClient, + StandardClient as MatrixStandardClient, + ExplodeClient as PathExplodeClient, + PathParametersClient, + StandardClient as PathStandardClient, + ExplodeClient as QueryExplodeClient, + QueryParametersClient, + StandardClient as QueryStandardClient, + ReservedExpansionClient, + RoutesClient, + ExplodeClient as SimpleExplodeClient, + StandardClient as SimpleStandardClient, +} from "../../generated/routes/src/index.js"; + +describe("Routes", () => { + const routesClient = new RoutesClient(); + const pathParametersClient = new PathParametersClient(); + const queryParametersClient = new QueryParametersClient(); + + describe("fixed", () => { + it("should call fixed operation at the root level", async () => { + await routesClient.fixed(); + }); + }); + + describe("InInterface", () => { + const inInterfaceClient = new InInterfaceClient(); + + it("should call fixed operation inside interface", async () => { + await inInterfaceClient.fixed(); + }); + }); + + describe("PathParameters", () => { + it("should handle implicit path parameter", async () => { + await pathParametersClient.templateOnly("a"); + }); + + it("should handle explicit @path parameter", async () => { + await pathParametersClient.explicit("a"); + }); + + it("should handle @path parameter without explicit route definition", async () => { + await pathParametersClient.annotationOnly("a"); + }); + + describe("ReservedExpansion", () => { + const reservedExpansionClient = new ReservedExpansionClient(); + + it("should handle reserved expansion with template", async () => { + await reservedExpansionClient.template("foo/bar baz"); + }); + + it("should handle reserved expansion with annotation", async () => { + await reservedExpansionClient.annotation("foo/bar baz"); + }); + }); + + describe("SimpleExpansion", () => { + describe("Standard", () => { + const simpleStandardClient = new SimpleStandardClient(); + + it("should handle primitive value with explode: false", async () => { + await simpleStandardClient.primitive("a"); + }); + + it("should handle array value with explode: false", async () => { + await simpleStandardClient.array(["a", "b"]); + }); + + it("should handle record value with explode: false", async () => { + await simpleStandardClient.record({ a: 1, b: 2 }); + }); + }); + + describe("Explode", () => { + const simpleExplodeClient = new SimpleExplodeClient(); + + it.skip("should handle primitive value with explode: true", async () => { + await simpleExplodeClient.primitive("a"); + }); + + it("should handle array value with explode: true", async () => { + await simpleExplodeClient.array(["a", "b"]); + }); + + it("should handle record value with explode: true", async () => { + await simpleExplodeClient.record({ a: 1, b: 2 }); + }); + }); + }); + + describe("PathExpansion", () => { + describe("Standard", () => { + const pathStandardClient = new PathStandardClient(); + + it("should handle primitive value with explode: false", async () => { + await pathStandardClient.primitive("a"); + }); + + it("should handle array value with explode: false", async () => { + await pathStandardClient.array(["a", "b"]); + }); + + it("should handle record value with explode: false", async () => { + await pathStandardClient.record({ a: 1, b: 2 }); + }); + }); + + describe("Explode", () => { + const pathExplodeClient = new PathExplodeClient(); + + it.skip("should handle primitive value with explode: true", async () => { + await pathExplodeClient.primitive("a"); + }); + + it("should handle array value with explode: true", async () => { + await pathExplodeClient.array(["a", "b"]); + }); + + it("should handle record value with explode: true", async () => { + await pathExplodeClient.record({ a: 1, b: 2 }); + }); + }); + }); + + describe("LabelExpansion", () => { + describe("Standard", () => { + const labelStandardClient = new LabelStandardClient(); + + it("should handle primitive value with explode: false", async () => { + await labelStandardClient.primitive("a"); + }); + + it("should handle array value with explode: false", async () => { + await labelStandardClient.array(["a", "b"]); + }); + + it("should handle record value with explode: false", async () => { + await labelStandardClient.record({ a: 1, b: 2 }); + }); + }); + + describe("Explode", () => { + const labelExplodeClient = new LabelExplodeClient(); + + it.skip("should handle primitive value with explode: true", async () => { + await labelExplodeClient.primitive("a"); + }); + + it("should handle array value with explode: true", async () => { + await labelExplodeClient.array(["a", "b"]); + }); + + it("should handle record value with explode: true", async () => { + await labelExplodeClient.record({ a: 1, b: 2 }); + }); + }); + }); + + describe("MatrixExpansion", () => { + describe("Standard", () => { + const matrixStandardClient = new MatrixStandardClient(); + + it("should handle primitive value with explode: false", async () => { + await matrixStandardClient.primitive("a"); + }); + + it("should handle array value with explode: false", async () => { + await matrixStandardClient.array(["a", "b"]); + }); + + it("should handle record value with explode: false", async () => { + await matrixStandardClient.record({ a: 1, b: 2 }); + }); + }); + + describe("Explode", () => { + const matrixExplodeClient = new MatrixExplodeClient(); + + it.skip("should handle primitive value with explode: true", async () => { + await matrixExplodeClient.primitive("a"); + }); + + it("should handle array value with explode: true", async () => { + await matrixExplodeClient.array(["a", "b"]); + }); + + it("should handle record value with explode: true", async () => { + await matrixExplodeClient.record({ a: 1, b: 2 }); + }); + }); + }); + }); + + describe("QueryParameters", () => { + it("should handle implicit query parameter", async () => { + await queryParametersClient.templateOnly("a"); + }); + + it("should handle explicit @query parameter", async () => { + await queryParametersClient.explicit("a"); + }); + + it("should handle @query parameter without explicit route definition", async () => { + await queryParametersClient.annotationOnly("a"); + }); + + describe("QueryExpansion", () => { + describe("Standard", () => { + const queryStandardClient = new QueryStandardClient(); + + it("should handle primitive value with explode: false", async () => { + await queryStandardClient.primitive("a"); + }); + + it("should handle array value with explode: false", async () => { + await queryStandardClient.array(["a", "b"]); + }); + + it("should handle record value with explode: false", async () => { + await queryStandardClient.record({ a: 1, b: 2 }); + }); + }); + + describe("Explode", () => { + const queryExplodeClient = new QueryExplodeClient(); + + it.skip("should handle primitive value with explode: true", async () => { + await queryExplodeClient.primitive("a"); + }); + + it("should handle array value with explode: true", async () => { + await queryExplodeClient.array(["a", "b"]); + }); + + it("should handle record value with explode: true", async () => { + await queryExplodeClient.record({ a: 1, b: 2 }); + }); + }); + }); + + describe("QueryContinuation", () => { + describe("Standard", () => { + const continuationStandardClient = new ContinuationStandardClient(); + + it("should handle primitive value with explode: false", async () => { + await continuationStandardClient.primitive("a"); + }); + + it("should handle array value with explode: false", async () => { + await continuationStandardClient.array(["a", "b"]); + }); + + it("should handle record value with explode: false", async () => { + await continuationStandardClient.record({ a: 1, b: 2 }); + }); + }); + + describe("Explode", () => { + const continuationExplodeClient = new ContinuationExplodeClient(); + + it.skip("should handle primitive value with explode: true", async () => { + await continuationExplodeClient.primitive("a"); + }); + + it("should handle array value with explode: true", async () => { + await continuationExplodeClient.array(["a", "b"]); + }); + + it("should handle record value with explode: true", async () => { + await continuationExplodeClient.record({ a: 1, b: 2 }); + }); + }); + }); + }); +}); diff --git a/packages/http-client-js/test/e2e/http/serialization/encoded-name/json/main.test.ts b/packages/http-client-js/test/e2e/http/serialization/encoded-name/json/main.test.ts new file mode 100644 index 00000000000..a269fde7a2a --- /dev/null +++ b/packages/http-client-js/test/e2e/http/serialization/encoded-name/json/main.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { PropertyClient } from "../../../../generated/serialization/encoded-name/json/src/index.js"; + +describe("Serialization.EncodedName.Json", () => { + describe("PropertyClient", () => { + const client = new PropertyClient({ allowInsecureConnection: true }); + + it("should send a JsonEncodedNameModel with 'defaultName' mapped to 'wireName'", async () => { + await client.send({ defaultName: true }); + // Assert successful request + }); + + it("should deserialize a JsonEncodedNameModel with 'wireName' mapped to 'defaultName'", async () => { + const response = await client.get(); + expect(response).toEqual({ + defaultName: true, + }); // Mock API expected value + }); + }); +}); diff --git a/packages/http-client-js/test/e2e/http/server/endpoint/not-defined/main.test.ts b/packages/http-client-js/test/e2e/http/server/endpoint/not-defined/main.test.ts new file mode 100644 index 00000000000..a29cb7a8005 --- /dev/null +++ b/packages/http-client-js/test/e2e/http/server/endpoint/not-defined/main.test.ts @@ -0,0 +1,11 @@ +import { describe, it } from "vitest"; +import { NotDefinedClient } from "../../../../generated/server/endpoint/not-defined/src/index.js"; + +describe("Server.Endpoint.NotDefined", () => { + const client = new NotDefinedClient("http://localhost:3000", { allowInsecureConnection: true }); + + it("should handle a request to a server without defining an endpoint", async () => { + await client.valid(); + // Assert successful request + }); +}); diff --git a/packages/http-client-js/test/e2e/http/server/path/multiple/main.test.ts b/packages/http-client-js/test/e2e/http/server/path/multiple/main.test.ts new file mode 100644 index 00000000000..e6b00a5941a --- /dev/null +++ b/packages/http-client-js/test/e2e/http/server/path/multiple/main.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { MultipleClient, Versions } from "../../../../generated/server/path/multiple/src/index.js"; + +describe("Server.Path.Multiple", () => { + const client = new MultipleClient("http://localhost:3000", Versions.V1_0, { + allowInsecureConnection: true, + retryOptions: { + maxRetries: 0, + }, + apiVersion: "v1.0", + }); + + it("should call operation with client path parameters", async () => { + const response = await client.noOperationParams(); + expect(response).toBeUndefined(); // Assert successful request + }); + + it("should call operation with client and method path parameters", async () => { + const response = await client.withOperationPathParam("test"); + expect(response).toBeUndefined(); // Assert successful request + }); +}); diff --git a/packages/http-client-js/test/e2e/http/server/path/single/main.test.ts b/packages/http-client-js/test/e2e/http/server/path/single/main.test.ts new file mode 100644 index 00000000000..86a10c62c19 --- /dev/null +++ b/packages/http-client-js/test/e2e/http/server/path/single/main.test.ts @@ -0,0 +1,11 @@ +import { describe, it } from "vitest"; +import { SingleClient } from "../../../../generated/server/path/single/src/index.js"; + +describe("Server.Path.Single", () => { + const client = new SingleClient("http://localhost:3000", { allowInsecureConnection: true }); + + it("should perform a simple operation in a parameterized server", async () => { + await client.myOp(); + // Assert successful request + }); +}); diff --git a/packages/http-client-js/test/e2e/http/server/versions/not-versioned/main.test.ts b/packages/http-client-js/test/e2e/http/server/versions/not-versioned/main.test.ts new file mode 100644 index 00000000000..16a87df1c53 --- /dev/null +++ b/packages/http-client-js/test/e2e/http/server/versions/not-versioned/main.test.ts @@ -0,0 +1,22 @@ +import { describe, it } from "vitest"; +import { NotVersionedClient } from "../../../../generated/server/versions/not-versioned/src/index.js"; + +describe("Server.Versions.NotVersioned", () => { + const client = new NotVersionedClient("http://localhost:3000", { allowInsecureConnection: true }); + + it("should execute operation 'withoutApiVersion' without an api-version", async () => { + await client.withoutApiVersion(); + // Assert successful request + }); + + // Issue with TypeSpec building an invalid uri template with - + it.skip("should execute operation 'withQueryApiVersion' with query api-version", async () => { + await client.withQueryApiVersion("v1.0"); + // Assert successful request + }); + + it("should execute operation 'withPathApiVersion' with path api-version", async () => { + await client.withPathApiVersion("v1.0"); + // Assert successful request + }); +}); diff --git a/packages/http-client-js/test/e2e/http/server/versions/versioned/main.test.ts b/packages/http-client-js/test/e2e/http/server/versions/versioned/main.test.ts new file mode 100644 index 00000000000..1371e1e9e73 --- /dev/null +++ b/packages/http-client-js/test/e2e/http/server/versions/versioned/main.test.ts @@ -0,0 +1,28 @@ +import { describe, it } from "vitest"; +import { VersionedClient } from "../../../../generated/server/versions/versioned/src/index.js"; + +describe("Server.Versions.Versioned", () => { + const client = new VersionedClient("http://localhost:3000", { allowInsecureConnection: true }); + + it("should perform operation without api-version in the URL", async () => { + await client.withoutApiVersion(); + // Assert successful request + }); + + // Issue with TypeSpec creating an invalid URL Template containing - + it.skip("should perform operation with query api-version, defaulting to '2022-12-01-preview'", async () => { + await client.withQueryApiVersion("2022-12-01-preview"); + // Assert successful request + }); + + it("should perform operation with path api-version, defaulting to '2022-12-01-preview'", async () => { + await client.withPathApiVersion("2022-12-01-preview"); + // Assert successful request + }); + + // Issue with TypeSpec creating an invalid URL Template containing - + it.skip("should perform operation with query api-version set to '2021-01-01-preview'", async () => { + await client.withQueryOldApiVersion("2021-01-01-preview"); + // Assert successful request + }); +}); diff --git a/packages/http-client-js/test/e2e/http/special-headers/conditional-request/main.test.ts b/packages/http-client-js/test/e2e/http/special-headers/conditional-request/main.test.ts new file mode 100644 index 00000000000..9355dd705b2 --- /dev/null +++ b/packages/http-client-js/test/e2e/http/special-headers/conditional-request/main.test.ts @@ -0,0 +1,35 @@ +import { describe, it } from "vitest"; +import { ConditionalRequestClient } from "../../../generated/special-headers/conditional-request/src/index.js"; + +describe("SpecialHeaders.ConditionalRequest", () => { + const client = new ConditionalRequestClient({ + allowInsecureConnection: true, + retryOptions: { + maxRetries: 0, + }, + }); + + // mockapi issue Double quotes + it.skip("should send a request with If-Match header defined", async () => { + await client.postIfMatch({ ifMatch: "valid" }); + // Assert successful request + }); + + // mockapi issue Double quotes + it.skip("should send a request with If-None-Match header defined", async () => { + await client.postIfNoneMatch({ ifNoneMatch: "invalid" }); + // Assert successful request + }); + + it("should send a request with If-Modified-Since header defined", async () => { + const date = new Date("Fri, 26 Aug 2022 14:38:00 GMT"); + await client.headIfModifiedSince({ ifModifiedSince: date }); + // Assert successful request + }); + + it("should send a request with If-Unmodified-Since header defined", async () => { + const date = new Date("Fri, 26 Aug 2022 14:38:00 GMT"); + await client.postIfUnmodifiedSince({ ifUnmodifiedSince: date }); + // Assert successful request + }); +}); diff --git a/packages/http-client-js/test/e2e/http/special-headers/repeatability/main.test.ts b/packages/http-client-js/test/e2e/http/special-headers/repeatability/main.test.ts new file mode 100644 index 00000000000..347bd751699 --- /dev/null +++ b/packages/http-client-js/test/e2e/http/special-headers/repeatability/main.test.ts @@ -0,0 +1,18 @@ +import { describe, it } from "vitest"; +import { RepeatabilityClient } from "../../../generated/special-headers/repeatability/src/index.js"; + +describe("SpecialHeaders.Repeatability", () => { + const client = new RepeatabilityClient({ + allowInsecureConnection: true, + retryOptions: { + maxRetries: 0, + }, + }); + + it("should recognize Repeatability-Request-ID and Repeatability-First-Sent headers", async () => { + const repeatabilityRequestID = "2378d9bc-1726-11ee-be56-0242ac120002"; + const repeatabilityFirstSent = new Date("Tue, 15 Nov 2022 12:45:26 GMT"); + + await client.immediateSuccess(repeatabilityRequestID, repeatabilityFirstSent); + }); +}); diff --git a/packages/http-client-js/test/e2e/http/special-words/main.test.ts b/packages/http-client-js/test/e2e/http/special-words/main.test.ts new file mode 100644 index 00000000000..82c3a88d9cb --- /dev/null +++ b/packages/http-client-js/test/e2e/http/special-words/main.test.ts @@ -0,0 +1,434 @@ +import { describe, it } from "vitest"; +import { + ModelPropertiesClient, + ModelsClient, + OperationsClient, + ParametersClient, +} from "../../generated/special-words/src/index.js"; + +describe("SpecialWords", () => { + describe("OperationsClient", () => { + const client = new OperationsClient({ allowInsecureConnection: true }); + + it("should handle operation 'and'", async () => { + await client.and(); + }); + + it("should handle operation 'as'", async () => { + await client.as_(); + }); + + it("should handle operation 'assert'", async () => { + await client.assert(); + }); + + it("should handle operation 'async'", async () => { + await client.async(); + }); + + it("should handle operation 'await'", async () => { + await client.await_(); + }); + + it("should handle operation 'break'", async () => { + await client.break_(); + }); + + it("should handle operation 'class'", async () => { + await client.class_(); + }); + + it("should handle operation 'constructor'", async () => { + await client.constructor_2(); + }); + + it("should handle operation 'continue'", async () => { + await client.continue_(); + }); + + it("should handle operation 'def'", async () => { + await client.def(); + }); + + it("should handle operation 'del'", async () => { + await client.del(); + }); + + it("should handle operation 'elif'", async () => { + await client.elif(); + }); + + it("should handle operation 'else'", async () => { + await client.else_(); + }); + + it("should handle operation 'except'", async () => { + await client.except(); + }); + + it("should handle operation 'exec'", async () => { + await client.exec(); + }); + + it("should handle operation 'finally'", async () => { + await client.finally_(); + }); + + it("should handle operation 'for'", async () => { + await client.for_(); + }); + + it("should handle operation 'from'", async () => { + await client.from(); + }); + + it("should handle operation 'global'", async () => { + await client.global(); + }); + + it("should handle operation 'if'", async () => { + await client.if_(); + }); + + it("should handle operation 'import'", async () => { + await client.import_(); + }); + + it("should handle operation 'in'", async () => { + await client.in_(); + }); + + it("should handle operation 'is'", async () => { + await client.is(); + }); + + it("should handle operation 'lambda'", async () => { + await client.lambda(); + }); + + it("should handle operation 'not'", async () => { + await client.not(); + }); + + it("should handle operation 'or'", async () => { + await client.or(); + }); + + it("should handle operation 'pass'", async () => { + await client.pass(); + }); + + it("should handle operation 'raise'", async () => { + await client.raise(); + }); + + it("should handle operation 'return'", async () => { + await client.return_(); + }); + + it("should handle operation 'try'", async () => { + await client.try_(); + }); + + it("should handle operation 'while'", async () => { + await client.while_(); + }); + + it("should handle operation 'with'", async () => { + await client.with_(); + }); + + it("should handle operation 'yield'", async () => { + await client.yield_(); + }); + }); + + describe("ParametersClient", () => { + const client = new ParametersClient({ allowInsecureConnection: true }); + + it("should handle parameter 'and'", async () => { + await client.withAnd("ok"); + }); + + it("should handle parameter 'as'", async () => { + await client.withAs("ok"); + }); + + it("should handle parameter 'assert'", async () => { + await client.withAssert("ok"); + }); + + it("should handle parameter 'async'", async () => { + await client.withAsync("ok"); + }); + + it("should handle parameter 'await'", async () => { + await client.withAwait("ok"); + }); + + it("should handle parameter 'break'", async () => { + await client.withBreak("ok"); + }); + + it("should handle parameter 'class'", async () => { + await client.withClass("ok"); + }); + + it("should handle parameter 'constructor'", async () => { + await client.withConstructor("ok"); + }); + + it("should handle parameter 'continue'", async () => { + await client.withContinue("ok"); + }); + + it("should handle parameter 'def'", async () => { + await client.withDef("ok"); + }); + + it("should handle parameter 'del'", async () => { + await client.withDel("ok"); + }); + + it("should handle parameter 'elif'", async () => { + await client.withElif("ok"); + }); + + it("should handle parameter 'else'", async () => { + await client.withElse("ok"); + }); + + it("should handle parameter 'except'", async () => { + await client.withExcept("ok"); + }); + + it("should handle parameter 'exec'", async () => { + await client.withExec("ok"); + }); + + it("should handle parameter 'finally'", async () => { + await client.withFinally("ok"); + }); + + it("should handle parameter 'for'", async () => { + await client.withFor("ok"); + }); + + it("should handle parameter 'from'", async () => { + await client.withFrom("ok"); + }); + + it("should handle parameter 'global'", async () => { + await client.withGlobal("ok"); + }); + + it("should handle parameter 'if'", async () => { + await client.withIf("ok"); + }); + + it("should handle parameter 'import'", async () => { + await client.withImport("ok"); + }); + + it("should handle parameter 'in'", async () => { + await client.withIn("ok"); + }); + + it("should handle parameter 'is'", async () => { + await client.withIs("ok"); + }); + + it("should handle parameter 'lambda'", async () => { + await client.withLambda("ok"); + }); + + it("should handle parameter 'not'", async () => { + await client.withNot("ok"); + }); + + it("should handle parameter 'or'", async () => { + await client.withOr("ok"); + }); + + it("should handle parameter 'pass'", async () => { + await client.withPass("ok"); + }); + + it("should handle parameter 'raise'", async () => { + await client.withRaise("ok"); + }); + + it("should handle parameter 'return'", async () => { + await client.withReturn("ok"); + }); + + it("should handle parameter 'try'", async () => { + await client.withTry("ok"); + }); + + it("should handle parameter 'while'", async () => { + await client.withWhile("ok"); + }); + + it("should handle parameter 'with'", async () => { + await client.withWith("ok"); + }); + + it("should handle parameter 'yield'", async () => { + await client.withYield("ok"); + }); + + it("should handle parameter 'cancellationToken'", async () => { + await client.withCancellationToken("ok"); + }); + }); + + describe("ModelsClient", () => { + const client = new ModelsClient({ allowInsecureConnection: true }); + + it("should handle model 'and'", async () => { + await client.withAnd({ name: "ok" }); + }); + + it("should handle model 'as'", async () => { + await client.withAs({ name: "ok" }); + }); + + it("should handle model 'assert'", async () => { + await client.withAssert({ name: "ok" }); + }); + + it("should handle model 'async'", async () => { + await client.withAsync({ name: "ok" }); + }); + + it("should handle model 'await'", async () => { + await client.withAwait({ name: "ok" }); + }); + + it("should handle model 'break'", async () => { + await client.withBreak({ name: "ok" }); + }); + + it("should handle model 'class'", async () => { + await client.withClass({ name: "ok" }); + }); + + it("should handle model 'constructor'", async () => { + await client.withConstructor({ name: "ok" }); + }); + + it("should handle model 'continue'", async () => { + await client.withContinue({ name: "ok" }); + }); + + it("should handle model 'def'", async () => { + await client.withDef({ name: "ok" }); + }); + + it("should handle model 'del'", async () => { + await client.withDel({ name: "ok" }); + }); + + it("should handle model 'elif'", async () => { + await client.withElif({ name: "ok" }); + }); + + it("should handle model 'else'", async () => { + await client.withElse({ name: "ok" }); + }); + + it("should handle model 'except'", async () => { + await client.withExcept({ name: "ok" }); + }); + + it("should handle model 'exec'", async () => { + await client.withExec({ name: "ok" }); + }); + + it("should handle model 'finally'", async () => { + await client.withFinally({ name: "ok" }); + }); + + it("should handle model 'for'", async () => { + await client.withFor({ name: "ok" }); + }); + + it("should handle model 'from'", async () => { + await client.withFrom({ name: "ok" }); + }); + + it("should handle model 'global'", async () => { + await client.withGlobal({ name: "ok" }); + }); + + it("should handle model 'if'", async () => { + await client.withIf({ name: "ok" }); + }); + + it("should handle model 'import'", async () => { + await client.withImport({ name: "ok" }); + }); + + it("should handle model 'in'", async () => { + await client.withIn({ name: "ok" }); + }); + + it("should handle model 'is'", async () => { + await client.withIs({ name: "ok" }); + }); + + it("should handle model 'lambda'", async () => { + await client.withLambda({ name: "ok" }); + }); + + it("should handle model 'not'", async () => { + await client.withNot({ name: "ok" }); + }); + + it("should handle model 'or'", async () => { + await client.withOr({ name: "ok" }); + }); + + it("should handle model 'pass'", async () => { + await client.withPass({ name: "ok" }); + }); + + it("should handle model 'raise'", async () => { + await client.withRaise({ name: "ok" }); + }); + + it("should handle model 'return'", async () => { + await client.withReturn({ name: "ok" }); + }); + + it("should handle model 'try'", async () => { + await client.withTry({ name: "ok" }); + }); + + it("should handle model 'while'", async () => { + await client.withWhile({ name: "ok" }); + }); + + it("should handle model 'with'", async () => { + await client.withWith({ name: "ok" }); + }); + + it("should handle model 'yield'", async () => { + await client.withYield({ name: "ok" }); + }); + }); + + describe("ModelPropertiesClient", () => { + const client = new ModelPropertiesClient({ + allowInsecureConnection: true, + retryOptions: { + maxRetries: 1, + }, + }); + + it("should handle property same as the model name", async () => { + await client.sameAsModel({ sameAsModel: "ok" }); + }); + }); +}); diff --git a/packages/http-client-js/test/e2e/http/type/array/array.test.ts b/packages/http-client-js/test/e2e/http/type/array/array.test.ts new file mode 100644 index 00000000000..fda1f4a91a2 --- /dev/null +++ b/packages/http-client-js/test/e2e/http/type/array/array.test.ts @@ -0,0 +1,256 @@ +import { describe, expect, it } from "vitest"; +import { + BooleanValueClient, + DatetimeValueClient, + DurationValueClient, + Float32ValueClient, + Int32ValueClient, + Int64ValueClient, + ModelValueClient, + NullableBooleanValueClient, + NullableFloatValueClient, + NullableInt32ValueClient, + NullableModelValueClient, + NullableStringValueClient, + StringValueClient, + UnknownValueClient, +} from "../../../generated/type/array/src/index.js"; + +describe("Type.Array", () => { + describe("Int32ValueClient", () => { + const client = new Int32ValueClient({ allowInsecureConnection: true }); + + it("should handle an array of int32 values returned from the server", async () => { + const response = await client.get(); + expect(response).toEqual([1, 2]); + }); + + it("should send an array of int32 values to the server", async () => { + await client.put([1, 2]); + }); + }); + + describe("Int64ValueClient", () => { + const client = new Int64ValueClient({ allowInsecureConnection: true }); + + it("should handle an array of int64 values returned from the server", async () => { + const response = await client.get(); + expect(response).toEqual([9007199254740991, -9007199254740991]); + }); + + it.skip("should send an array of int64 values to the server", async () => { + await client.put([0x7fffffffffffffffn, -0x7fffffffffffffffn]); + }); + }); + + describe("BooleanValueClient", () => { + const client = new BooleanValueClient({ + allowInsecureConnection: true, + retryOptions: { + maxRetries: 1, + }, + }); + + it("should handle an array of boolean values returned from the server", async () => { + const response = await client.get(); + expect(response).toEqual([true, false]); + }); + + it("should send an array of boolean values to the server", async () => { + await client.put([true, false]); + }); + }); + + describe("StringValueClient", () => { + const client = new StringValueClient({ + allowInsecureConnection: true, + retryOptions: { + maxRetries: 1, + }, + }); + + it("should handle an array of string values returned from the server", async () => { + const response = await client.get(); + expect(response).toEqual(["hello", ""]); + }); + + it("should send an array of string values to the server", async () => { + await client.put(["hello", ""]); + }); + }); + + describe("Float32ValueClient", () => { + const client = new Float32ValueClient({ + allowInsecureConnection: true, + retryOptions: { + maxRetries: 1, + }, + }); + + it("should handle an array of float values returned from the server", async () => { + const response = await client.get(); + expect(response).toEqual([43.125]); + }); + + it("should send an array of float values to the server", async () => { + await client.put([43.125]); + }); + }); + + describe("DatetimeValueClient", () => { + const client = new DatetimeValueClient({ + allowInsecureConnection: true, + retryOptions: { + maxRetries: 1, + }, + }); + + it("should handle an array of datetime values returned from the server", async () => { + const response = await client.get(); + expect(response).toEqual([new Date("2022-08-26T18:38:00Z")]); + }); + + it("should send an array of datetime values to the server", async () => { + await client.put([new Date("2022-08-26T18:38:00Z")]); + }); + }); + + describe("DurationValueClient", () => { + const client = new DurationValueClient({ + allowInsecureConnection: true, + retryOptions: { + maxRetries: 1, + }, + }); + + it("should handle an array of duration values returned from the server", async () => { + const response = await client.get(); + expect(response).toEqual(["P123DT22H14M12.011S"]); + }); + + it("should send an array of duration values to the server", async () => { + await client.put(["P123DT22H14M12.011S"]); + }); + }); + + describe("UnknownValueClient", () => { + const client = new UnknownValueClient({ + allowInsecureConnection: true, + retryOptions: { + maxRetries: 1, + }, + }); + + it("should handle an array of unknown values returned from the server", async () => { + const response = await client.get(); + expect(response).toEqual([1, "hello", null]); + }); + + it("should send an array of unknown values to the server", async () => { + await client.put([1, "hello", null]); + }); + }); + + describe("ModelValueClient", () => { + const client = new ModelValueClient({ allowInsecureConnection: true }); + + it("should handle an array of model values returned from the server", async () => { + const response = await client.get(); + expect(response).toEqual([{ property: "hello" }, { property: "world" }]); + }); + + it("should send an array of model values to the server", async () => { + await client.put([{ property: "hello" }, { property: "world" }]); + }); + }); + + describe("NullableFloatValueClient", () => { + const client = new NullableFloatValueClient({ + allowInsecureConnection: true, + retryOptions: { + maxRetries: 1, + }, + }); + + it("should handle an array of nullable float values returned from the server", async () => { + const response = await client.get(); + expect(response).toEqual([1.25, null, 3.0]); + }); + + it("should send an array of nullable float values to the server", async () => { + await client.put([1.25, null, 3.0]); + }); + }); + + describe("NullableInt32ValueClient", () => { + const client = new NullableInt32ValueClient({ + allowInsecureConnection: true, + retryOptions: { + maxRetries: 1, + }, + }); + + it("should handle an array of nullable int32 values returned from the server", async () => { + const response = await client.get(); + expect(response).toEqual([1, null, 3]); + }); + + it("should send an array of nullable int32 values to the server", async () => { + await client.put([1, null, 3]); + }); + }); + + describe("NullableBooleanValueClient", () => { + const client = new NullableBooleanValueClient({ + allowInsecureConnection: true, + retryOptions: { + maxRetries: 1, + }, + }); + + it("should handle an array of nullable boolean values returned from the server", async () => { + const response = await client.get(); + expect(response).toEqual([true, null, false]); + }); + + it("should send an array of nullable boolean values to the server", async () => { + await client.put([true, null, false]); + }); + }); + + describe("NullableStringValueClient", () => { + const client = new NullableStringValueClient({ + allowInsecureConnection: true, + retryOptions: { + maxRetries: 1, + }, + }); + + it("should handle an array of nullable string values returned from the server", async () => { + const response = await client.get(); + expect(response).toEqual(["hello", null, "world"]); + }); + + it("should send an array of nullable string values to the server", async () => { + await client.put(["hello", null, "world"]); + }); + }); + + describe("NullableModelValueClient", () => { + const client = new NullableModelValueClient({ + allowInsecureConnection: true, + retryOptions: { + maxRetries: 1, + }, + }); + + it("should handle an array of nullable model values returned from the server", async () => { + const response = await client.get(); + expect(response).toEqual([{ property: "hello" }, null, { property: "world" }]); + }); + + it("should send an array of nullable model values to the server", async () => { + await client.put([{ property: "hello" }, null, { property: "world" }]); + }); + }); +}); diff --git a/packages/http-client-js/test/e2e/http/type/dictionary/main.test.ts b/packages/http-client-js/test/e2e/http/type/dictionary/main.test.ts new file mode 100644 index 00000000000..5ed6be3dc22 --- /dev/null +++ b/packages/http-client-js/test/e2e/http/type/dictionary/main.test.ts @@ -0,0 +1,229 @@ +import { describe, expect, it } from "vitest"; +import { + BooleanValueClient, + DatetimeValueClient, + DurationValueClient, + Float32ValueClient, + Int32ValueClient, + Int64ValueClient, + ModelValueClient, + NullableFloatValueClient, + RecursiveModelValueClient, + StringValueClient, + UnknownValueClient, +} from "../../../generated/type/dictionary/src/index.js"; + +describe("Type.Dictionary", () => { + describe("Int32ValueClient", () => { + const client = new Int32ValueClient({ allowInsecureConnection: true }); + + it("should handle a dictionary of int32 values returned from the server", async () => { + const response = await client.get(); + expect(response).toEqual({ k1: 1, k2: 2 }); + }); + + it("should send a dictionary of int32 values to the server", async () => { + await client.put({ k1: 1, k2: 2 }); + }); + }); + + describe("Int64ValueClient", () => { + const client = new Int64ValueClient({ allowInsecureConnection: true }); + + it("should handle a dictionary of int64 values returned from the server", async () => { + // Currently, we adjust our expectations to match the maximum safe integer (`Number.MAX_SAFE_INTEGER`) + // since JSON does not support `BigInt`, leading to precision loss when serializing and deserializing. + // + // In the future, we might consider encoding `BigInt` values as strings in API responses and handling + // them explicitly during parsing to preserve full precision. + const response = await client.get(); + expect(response).toEqual({ + k1: Number.MAX_SAFE_INTEGER, + k2: Number.MIN_SAFE_INTEGER, + }); + }); + + it.skip("should send a dictionary of int64 values to the server", async () => { + // Need to teach core how to handle `BigInt` values in JSON payloads. + await client.put({ + k1: 0x7fffffffffffffffn, + k2: -0x7fffffffffffffffn, + }); + }); + }); + + describe("BooleanValueClient", () => { + const client = new BooleanValueClient({ + allowInsecureConnection: true, + retryOptions: { + maxRetries: 1, + }, + }); + + it("should handle a dictionary of boolean values returned from the server", async () => { + const response = await client.get(); + expect(response).toEqual({ k1: true, k2: false }); + }); + + it("should send a dictionary of boolean values to the server", async () => { + await client.put({ k1: true, k2: false }); + }); + }); + + describe("StringValueClient", () => { + const client = new StringValueClient({ + allowInsecureConnection: true, + retryOptions: { + maxRetries: 1, + }, + }); + + it("should handle a dictionary of string values returned from the server", async () => { + const response = await client.get(); + expect(response).toEqual({ k1: "hello", k2: "" }); + }); + + it("should send a dictionary of string values to the server", async () => { + await client.put({ k1: "hello", k2: "" }); + }); + }); + + describe("Float32ValueClient", () => { + const client = new Float32ValueClient({ + allowInsecureConnection: true, + retryOptions: { + maxRetries: 1, + }, + }); + + it("should handle a dictionary of float32 values returned from the server", async () => { + const response = await client.get(); + expect(response).toEqual({ k1: 43.125 }); + }); + + it("should send a dictionary of float32 values to the server", async () => { + await client.put({ k1: 43.125 }); + }); + }); + + describe("DatetimeValueClient", () => { + const client = new DatetimeValueClient({ + allowInsecureConnection: true, + retryOptions: { + maxRetries: 1, + }, + }); + + it("should handle a dictionary of datetime values returned from the server", async () => { + const response = await client.get(); + expect(response).toEqual({ k1: new Date("2022-08-26T18:38:00Z") }); + }); + + it("should send a dictionary of datetime values to the server", async () => { + await client.put({ k1: new Date("2022-08-26T18:38:00Z") }); + }); + }); + + describe("DurationValueClient", () => { + const client = new DurationValueClient({ + allowInsecureConnection: true, + retryOptions: { + maxRetries: 1, + }, + }); + + it("should handle a dictionary of duration values returned from the server", async () => { + const response = await client.get(); + expect(response).toEqual({ k1: "P123DT22H14M12.011S" }); + }); + + it("should send a dictionary of duration values to the server", async () => { + await client.put({ k1: "P123DT22H14M12.011S" }); + }); + }); + + describe("UnknownValueClient", () => { + const client = new UnknownValueClient({ + allowInsecureConnection: true, + retryOptions: { + maxRetries: 1, + }, + }); + + it("should handle a dictionary of unknown values returned from the server", async () => { + const response = await client.get(); + expect(response).toEqual({ k1: 1, k2: "hello", k3: null }); + }); + + it("should send a dictionary of unknown values to the server", async () => { + await client.put({ k1: 1, k2: "hello", k3: null }); + }); + }); + + describe("ModelValueClient", () => { + const client = new ModelValueClient({ allowInsecureConnection: true }); + + it("should handle a dictionary of model values returned from the server", async () => { + const response = await client.get(); + expect(response).toEqual({ + k1: { property: "hello" }, + k2: { property: "world" }, + }); + }); + + it("should send a dictionary of model values to the server", async () => { + await client.put({ + k1: { property: "hello" }, + k2: { property: "world" }, + }); + }); + }); + + describe("RecursiveModelValueClient", () => { + const client = new RecursiveModelValueClient({ + allowInsecureConnection: true, + retryOptions: { + maxRetries: 1, + }, + }); + + it("should handle a dictionary of recursive model values returned from the server", async () => { + const response = await client.get(); + expect(response).toEqual({ + k1: { property: "hello", children: {} }, + k2: { + property: "world", + children: { "k2.1": { property: "inner world" } }, + }, + }); + }); + + it("should send a dictionary of recursive model values to the server", async () => { + await client.put({ + k1: { property: "hello", children: {} }, + k2: { + property: "world", + children: { "k2.1": { property: "inner world" } }, + }, + }); + }); + }); + + describe("NullableFloatValueClient", () => { + const client = new NullableFloatValueClient({ + allowInsecureConnection: true, + retryOptions: { + maxRetries: 1, + }, + }); + + it("should handle a dictionary of nullable float values returned from the server", async () => { + const response = await client.get(); + expect(response).toEqual({ k1: 1.25, k2: 0.5, k3: null }); + }); + + it("should send a dictionary of nullable float values to the server", async () => { + await client.put({ k1: 1.25, k2: 0.5, k3: null }); + }); + }); +}); diff --git a/packages/http-client-js/test/e2e/http/type/enum/extensible/main.test.ts b/packages/http-client-js/test/e2e/http/type/enum/extensible/main.test.ts new file mode 100644 index 00000000000..faedacbb82f --- /dev/null +++ b/packages/http-client-js/test/e2e/http/type/enum/extensible/main.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { StringClient } from "../../../../generated/type/enum/extensible/src/index.js"; + +describe("Type.Enum.Extensible", () => { + describe("StringClient", () => { + const client = new StringClient({ allowInsecureConnection: true }); + + it("should handle a known value returned from the server", async () => { + const response = await client.getKnownValue(); + expect(response).toBe("Monday"); // Mock API expected value + }); + + it("should handle an unknown value returned from the server", async () => { + const response = await client.getUnknownValue(); + expect(response).toBe("Weekend"); // Mock API expected value + }); + + // Issue with spector mock + it.skip("should send a known value to the server", async () => { + await client.putKnownValue("Monday"); + // Assert successful request + }); + + // Issue with Spector mock + it.skip("should send an unknown value to the server", async () => { + try { + await client.putUnknownValue("Weekend"); + } catch (err: any) { + expect(err.response?.status).toBe("500"); + } + // Assert successful request + }); + }); +}); diff --git a/packages/http-client-js/test/e2e/http/type/enum/fixed/main.test.ts b/packages/http-client-js/test/e2e/http/type/enum/fixed/main.test.ts new file mode 100644 index 00000000000..2e03e574a57 --- /dev/null +++ b/packages/http-client-js/test/e2e/http/type/enum/fixed/main.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { DaysOfWeekEnum, StringClient } from "../../../../generated/type/enum/fixed/src/index.js"; + +describe("Type.Enum.Fixed", () => { + describe("StringClient", () => { + const client = new StringClient({ allowInsecureConnection: true }); + + it("should handle a known value returned from the server", async () => { + const response = await client.getKnownValue(); + expect(response).toBe("Monday"); // Mock API expected value + }); + + // Mockapi issue expecting plain/text + it.skip("should send a known value to the server", async () => { + await client.putKnownValue(DaysOfWeekEnum.Monday); + // Assert successful request + }); + + // Mockapi issue expecting plain/text + it.skip("should send an unknown value to the server", async () => { + try { + await client.putUnknownValue("Weekend" as any); + throw new Error("Expected error with status code 500 but request succeeded"); + } catch (err: any) { + expect(err.response?.status).toBe("500"); + } + }); + }); +}); diff --git a/packages/http-client-js/test/e2e/http/type/model/empty/main.test.ts b/packages/http-client-js/test/e2e/http/type/model/empty/main.test.ts new file mode 100644 index 00000000000..dd56f22420a --- /dev/null +++ b/packages/http-client-js/test/e2e/http/type/model/empty/main.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { EmptyClient } from "../../../../generated/type/model/empty/src/index.js"; + +describe("Type.Model.Empty", () => { + const client = new EmptyClient({ allowInsecureConnection: true }); + + it("should send a PUT request with an empty body", async () => { + await client.putEmpty({}); + // Assert successful request + }); + + it("should handle a GET request returning an empty body", async () => { + const response = await client.getEmpty(); + expect(response).toEqual({}); + }); + + it("should send a POST request with an empty body and receive the same", async () => { + const response = await client.postRoundTripEmpty({}); + expect(response).toEqual({}); + }); +}); diff --git a/packages/http-client-js/test/e2e/http/type/model/inheritance/enum-discriminator/main.test.ts b/packages/http-client-js/test/e2e/http/type/model/inheritance/enum-discriminator/main.test.ts new file mode 100644 index 00000000000..47f0aafe974 --- /dev/null +++ b/packages/http-client-js/test/e2e/http/type/model/inheritance/enum-discriminator/main.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { EnumDiscriminatorClient } from "../../../../../generated/type/model/inheritance/enum-discriminator/src/index.js"; + +describe("Type.Model.Inheritance.EnumDiscriminator", () => { + const client = new EnumDiscriminatorClient({ + allowInsecureConnection: true, + retryOptions: { + maxRetries: 0, + }, + }); + + it("should receive model with extensible enum discriminator type", async () => { + const response = await client.getExtensibleModel(); + expect(response).toEqual({ kind: "golden", weight: 10 }); // Mock API expected response + }); + + it("should send model with extensible enum discriminator type", async () => { + await client.putExtensibleModel({ kind: "golden", weight: 10 }); + // Assert successful request + }); + + it("should get a model omitting the discriminator with extensible enum type", async () => { + const response = await client.getExtensibleModelMissingDiscriminator(); + expect(response).toEqual({ weight: 10 }); // Mock API expected response + }); + + it("should get a model containing discriminator value never defined with extensible enum type", async () => { + const response = await client.getExtensibleModelWrongDiscriminator(); + expect(response).toEqual({ kind: "wrongKind", weight: 8 }); // Mock API expected response + }); + + it("should receive model with fixed enum discriminator type", async () => { + const response = await client.getFixedModel(); + expect(response).toEqual({ kind: "cobra", length: 10 }); // Mock API expected response + }); + + it("should send model with fixed enum discriminator type", async () => { + await client.putFixedModel({ kind: "cobra", length: 10 }); + // Assert successful request + }); + + it("should get a model omitting the discriminator with fixed enum type", async () => { + const response = await client.getFixedModelMissingDiscriminator(); + expect(response).toEqual({ length: 10 }); // Mock API expected response + }); + + it("should get a model containing discriminator value never defined with fixed enum type", async () => { + const response = await client.getFixedModelWrongDiscriminator(); + expect(response).toEqual({ kind: "wrongKind", length: 8 }); // Mock API expected response + }); +}); diff --git a/packages/http-client-js/test/e2e/http/type/model/inheritance/nested-discriminator/main.test.ts b/packages/http-client-js/test/e2e/http/type/model/inheritance/nested-discriminator/main.test.ts new file mode 100644 index 00000000000..48df6edca65 --- /dev/null +++ b/packages/http-client-js/test/e2e/http/type/model/inheritance/nested-discriminator/main.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from "vitest"; +import { NestedDiscriminatorClient } from "../../../../../generated/type/model/inheritance/nested-discriminator/src/index.js"; + +describe("Type.Model.Inheritance.NestedDiscriminator", () => { + const client = new NestedDiscriminatorClient({ + allowInsecureConnection: true, + retryOptions: { + maxRetries: 0, + }, + }); + + it("should get a polymorphic model with 2 discriminators", async () => { + const response = await client.getModel(); + expect(response).toEqual({ + age: 1, + kind: "shark", + sharktype: "goblin", + }); + }); + + it("should send a polymorphic model with 2 discriminators", async () => { + const input = { + age: 1, + kind: "shark", + sharktype: "goblin", + }; + await client.putModel(input); + // Assert successful request + }); + + it("should get a recursive polymorphic model", async () => { + const response = await client.getRecursiveModel(); + expect(response).toEqual({ + age: 1, + kind: "salmon", + partner: { + age: 2, + kind: "shark", + sharktype: "saw", + }, + friends: [ + { + age: 2, + kind: "salmon", + partner: { + age: 3, + kind: "salmon", + }, + hate: { + key1: { + age: 4, + kind: "salmon", + }, + key2: { + age: 2, + kind: "shark", + sharktype: "goblin", + }, + }, + }, + { + age: 3, + kind: "shark", + sharktype: "goblin", + }, + ], + hate: { + key3: { + age: 3, + kind: "shark", + sharktype: "saw", + }, + key4: { + age: 2, + kind: "salmon", + friends: [ + { + age: 1, + kind: "salmon", + }, + { + age: 4, + kind: "shark", + sharktype: "goblin", + }, + ], + }, + }, + }); + }); + + it("should send a recursive polymorphic model", async () => { + const input = { + age: 1, + kind: "salmon", + partner: { + age: 2, + kind: "shark", + sharktype: "saw", + }, + friends: [ + { + age: 2, + kind: "salmon", + partner: { + age: 3, + kind: "salmon", + }, + hate: { + key1: { + age: 4, + kind: "salmon", + }, + key2: { + age: 2, + kind: "shark", + sharktype: "goblin", + }, + }, + }, + { + age: 3, + kind: "shark", + sharktype: "goblin", + }, + ], + hate: { + key3: { + age: 3, + kind: "shark", + sharktype: "saw", + }, + key4: { + age: 2, + kind: "salmon", + friends: [ + { + age: 1, + kind: "salmon", + }, + { + age: 4, + kind: "shark", + sharktype: "goblin", + }, + ], + }, + }, + }; + await client.putRecursiveModel(input); + // Assert successful request + }); + + it("should get a model omitting the discriminator", async () => { + const response = await client.getMissingDiscriminator(); + expect(response).toEqual({ + age: 1, + }); + }); + + it("should get a model with a wrong discriminator value", async () => { + const response = await client.getWrongDiscriminator(); + expect(response).toEqual({ + age: 1, + kind: "wrongKind", + }); + }); +}); diff --git a/packages/http-client-js/test/e2e/http/type/model/inheritance/not-discriminated/main.test.ts b/packages/http-client-js/test/e2e/http/type/model/inheritance/not-discriminated/main.test.ts new file mode 100644 index 00000000000..3483bf2305d --- /dev/null +++ b/packages/http-client-js/test/e2e/http/type/model/inheritance/not-discriminated/main.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { NotDiscriminatedClient } from "../../../../../generated/type/model/inheritance/not-discriminated/src/index.js"; + +describe("Type.Model.Inheritance.NotDiscriminated", () => { + const client = new NotDiscriminatedClient({ + allowInsecureConnection: true, + retryOptions: { + maxRetries: 0, + }, + }); + + it("should generate and send model", async () => { + await client.postValid({ + name: "abc", + age: 32, + smart: true, + }); + // Assert successful request + }); + + it("should generate and receive model", async () => { + const response = await client.getValid(); + expect(response).toEqual({ + name: "abc", + age: 32, + smart: true, + }); // Mock API expected value + }); + + it("should generate, send, and receive round-trip bottom model", async () => { + const response = await client.putValid({ + name: "abc", + age: 32, + smart: true, + }); + expect(response).toEqual({ + name: "abc", + age: 32, + smart: true, + }); // Mock API expected value + }); +}); diff --git a/packages/http-client-js/test/e2e/http/type/model/inheritance/recursive/main.test.ts b/packages/http-client-js/test/e2e/http/type/model/inheritance/recursive/main.test.ts new file mode 100644 index 00000000000..b37d3305e48 --- /dev/null +++ b/packages/http-client-js/test/e2e/http/type/model/inheritance/recursive/main.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { RecursiveClient } from "../../../../../generated/type/model/inheritance/recursive/src/index.js"; + +describe("Type.Model.Inheritance.Recursive", () => { + const client = new RecursiveClient({ allowInsecureConnection: true }); + + it("should send a PUT request with a recursive Extension model", async () => { + await client.put({ + level: 0, + extension: [ + { + level: 1, + extension: [ + { + level: 2, + }, + ], + }, + { + level: 1, + }, + ], + }); + // Assert successful request + }); + + it("should handle a GET request returning a recursive Extension model", async () => { + const response = await client.get(); + expect(response).toEqual({ + level: 0, + extension: [ + { + level: 1, + extension: [ + { + level: 2, + }, + ], + }, + { + level: 1, + }, + ], + }); // Mock API expected value + }); +}); diff --git a/packages/http-client-js/test/e2e/http/type/model/inheritance/single-discriminator/main.test.ts b/packages/http-client-js/test/e2e/http/type/model/inheritance/single-discriminator/main.test.ts new file mode 100644 index 00000000000..fa32226c205 --- /dev/null +++ b/packages/http-client-js/test/e2e/http/type/model/inheritance/single-discriminator/main.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vitest"; +import { + Eagle, + SingleDiscriminatorClient, +} from "../../../../../generated/type/model/inheritance/single-discriminator/src/index.js"; + +describe("Type.Model.Inheritance.SingleDiscriminator", () => { + const client = new SingleDiscriminatorClient({ + allowInsecureConnection: true, + retryOptions: { + maxRetries: 0, + }, + }); + + it("should receive polymorphic model in single level inheritance with 1 discriminator", async () => { + const response = await client.getModel(); + expect(response).toEqual({ + wingspan: 1, + kind: "sparrow", + }); + }); + + it("should send polymorphic model in single level inheritance with 1 discriminator", async () => { + await client.putModel({ + wingspan: 1, + kind: "sparrow", + }); + // Assert successful request + }); + + it("should receive polymorphic models with collection and dictionary properties referring to other polymorphic models", async () => { + const response = await client.getRecursiveModel(); + expect(response).toEqual({ + wingspan: 5, + kind: "eagle", + partner: { + wingspan: 2, + kind: "goose", + }, + friends: [ + { + wingspan: 2, + kind: "seagull", + }, + ], + hate: { + key3: { + wingspan: 1, + kind: "sparrow", + }, + }, + }); + }); + + it("should send polymorphic models with collection and dictionary properties referring to other polymorphic models", async () => { + await client.putRecursiveModel({ + wingspan: 5, + kind: "eagle", + partner: { + wingspan: 2, + kind: "goose", + }, + friends: [ + { + wingspan: 2, + kind: "seagull", + }, + ], + hate: { + key3: { + wingspan: 1, + kind: "sparrow", + }, + }, + } as Eagle); + // Assert successful request + }); + + it("should handle a model omitting the discriminator", async () => { + const response = await client.getMissingDiscriminator(); + expect(response).toEqual({ + wingspan: 1, + }); + }); + + it("should handle a model containing a discriminator value that was never defined", async () => { + const response = await client.getWrongDiscriminator(); + expect(response).toEqual({ + wingspan: 1, + kind: "wrongKind", + }); + }); + + it("should receive polymorphic model defined in a legacy way", async () => { + const response = await client.getLegacyModel(); + expect(response).toEqual({ + size: 20, + kind: "t-rex", + }); + }); +}); diff --git a/packages/http-client-js/test/e2e/http/type/model/visibility/main.test.ts b/packages/http-client-js/test/e2e/http/type/model/visibility/main.test.ts new file mode 100644 index 00000000000..0c5de688ae2 --- /dev/null +++ b/packages/http-client-js/test/e2e/http/type/model/visibility/main.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { VisibilityClient } from "../../../../generated/type/model/visibility/src/index.js"; + +describe("Type.Model.Visibility", () => { + const client = new VisibilityClient({ allowInsecureConnection: true }); + + it("should generate and receive an output model with readonly properties (getModel)", async () => { + const input = { + queryProp: 123, + }; + const response = await client.getModel(input as any); + expect(response.readProp).toBe("abc"); + }); + + it("should send a model with write/create properties (headModel)", async () => { + const input = { + queryProp: 123, + }; + await client.headModel(input as any); + }); + + it("should send a model with write/create/update properties (putModel)", async () => { + const input = { + createProp: ["foo", "bar"], + updateProp: [1, 2], + }; + await client.putModel(input as any); + // Assert successful request + }); + + it("should send a model with write/update properties (patchModel)", async () => { + const input = { + updateProp: [1, 2], + }; + await client.patchModel(input as any); + // Assert successful request + }); + + it("should send a model with write/create properties (postModel)", async () => { + const input = { + createProp: ["foo", "bar"], + }; + await client.postModel(input as any); + // Assert successful request + }); + + it("should send a model with write/delete properties (deleteModel)", async () => { + const input = { + deleteProp: true, + }; + await client.deleteModel(input as any); + // Assert successful request + }); + + it("should send and receive a model with readonly properties (putReadOnlyModel)", async () => { + const input = {}; + const response = await client.putReadOnlyModel(input); + expect(response.optionalNullableIntList).toEqual([1, 2, 3]); + expect(response.optionalStringRecord).toEqual({ + k1: "value1", + k2: "value2", + }); + }); +}); diff --git a/packages/http-client-js/test/e2e/http/type/property/additional-properties/main.test.ts b/packages/http-client-js/test/e2e/http/type/property/additional-properties/main.test.ts new file mode 100644 index 00000000000..f7cf77f74b7 --- /dev/null +++ b/packages/http-client-js/test/e2e/http/type/property/additional-properties/main.test.ts @@ -0,0 +1,185 @@ +import { describe, expect, it } from "vitest"; +import { + ExtendsUnknownAdditionalPropertiesDiscriminatedDerived, + ExtendsUnknownClient, + ExtendsUnknownDerivedClient, + ExtendsUnknownDiscriminatedClient, + IsUnknownClient, + IsUnknownDerivedClient, + IsUnknownDiscriminatedClient, +} from "../../../../generated/type/property/additional-properties/src/index.js"; + +describe("Type.Property.AdditionalProperties", () => { + describe("ExtendsUnknownClient", () => { + const client = new ExtendsUnknownClient({ + allowInsecureConnection: true, + retryOptions: { + maxRetries: 1, + }, + }); + it("Expected response body: {'name': 'ExtendsUnknownAdditionalProperties', 'prop1': 32, 'prop2': true, 'prop3': 'abc'}", async () => { + const response = await client.get(); + expect(response).toEqual({ + additionalProperties: { + prop1: 32, + prop2: true, + prop3: "abc", + }, + name: "ExtendsUnknownAdditionalProperties", + }); + }); + it("Expected input body: {'name': 'ExtendsUnknownAdditionalProperties', 'prop1': 32, 'prop2': true, 'prop3': 'abc'}", async () => { + await client.put({ + additionalProperties: { prop1: 32, prop2: true, prop3: "abc" }, + name: "ExtendsUnknownAdditionalProperties", + }); + }); + }); + + describe("ExtendsUnknownDerivedClient", () => { + const client = new ExtendsUnknownDerivedClient({ + allowInsecureConnection: true, + retryOptions: { + maxRetries: 1, + }, + }); + it("Expected response body: {'name': 'ExtendsUnknownAdditionalProperties', 'index': 314, 'age': 2.71875, 'prop1': 32, 'prop2': true, 'prop3': 'abc'}", async () => { + const response = await client.get(); + expect(response).toEqual({ + additionalProperties: { + prop1: 32, + prop2: true, + prop3: "abc", + }, + name: "ExtendsUnknownAdditionalProperties", + index: 314, + age: 2.71875, + }); + }); + it("Expected input body: {'name': 'ExtendsUnknownAdditionalProperties', 'index': 314, 'age': 2.71875, 'prop1': 32, 'prop2': true, 'prop3': 'abc'}", async () => { + await client.put({ + name: "ExtendsUnknownAdditionalProperties", + index: 314, + age: 2.71875, + additionalProperties: { prop1: 32, prop2: true, prop3: "abc" }, + }); + }); + }); + + describe("ExtendsUnknownDiscriminatedClient", () => { + const client = new ExtendsUnknownDiscriminatedClient({ + allowInsecureConnection: true, + retryOptions: { + maxRetries: 1, + }, + }); + it("Expected response body: {'kind': 'derived', 'name': 'Derived', 'index': 314, 'age': 2.71875, 'prop1': 32, 'prop2': true, 'prop3': 'abc'}", async () => { + const response = await client.get(); + expect(response).toEqual({ + additionalProperties: { prop1: 32, prop2: true, prop3: "abc" }, + kind: "derived", + name: "Derived", + index: 314, + age: 2.71875, + }); + }); + it("Expected input body: {'kind': 'derived', 'name': 'Derived', 'index': 314, 'age': 2.71875, 'prop1': 32, 'prop2': true, 'prop3': 'abc'}", async () => { + const input: ExtendsUnknownAdditionalPropertiesDiscriminatedDerived = { + index: 314, + age: 2.71875, + kind: "derived", + name: "Derived", + additionalProperties: { + prop1: 32, + prop2: true, + prop3: "abc", + }, + }; + await client.put(input); + }); + }); + + describe("IsUnknownClient", () => { + const client = new IsUnknownClient({ allowInsecureConnection: true }); + it("Expected response body: {'name': 'IsUnknownAdditionalProperties', 'prop1': 32, 'prop2': true, 'prop3': 'abc'}", async () => { + const response = await client.get(); + expect(response).toEqual({ + name: "IsUnknownAdditionalProperties", + additionalProperties: { prop1: 32, prop2: true, prop3: "abc" }, + }); + }); + + it("Expected input body: {'name': 'IsUnknownAdditionalProperties', 'prop1': 32, 'prop2': true, 'prop3': 'abc'}", async () => { + await client.put({ + name: "IsUnknownAdditionalProperties", + additionalProperties: { + prop1: 32, + prop2: true, + prop3: "abc", + }, + }); + }); + }); + + describe("IsUnknownDerivedClient", () => { + const client = new IsUnknownDerivedClient({ + allowInsecureConnection: true, + retryOptions: { + maxRetries: 1, + }, + }); + it("Expected response body: {'name': 'IsUnknownAdditionalProperties', 'index': 314, 'age': 2.71875, 'prop1': 32, 'prop2': true, 'prop3': 'abc'}", async () => { + const response = await client.get(); + expect(response).toEqual({ + name: "IsUnknownAdditionalProperties", + index: 314, + age: 2.71875, + additionalProperties: { prop1: 32, prop2: true, prop3: "abc" }, + }); + }); + it("Expected input body: {'name': 'IsUnknownAdditionalProperties', 'index': 314, 'age': 2.71875, 'prop1': 32, 'prop2': true, 'prop3': 'abc'}", async () => { + await client.put({ + name: "IsUnknownAdditionalProperties", + index: 314, + age: 2.71875, + additionalProperties: { + prop1: 32, + prop2: true, + prop3: "abc", + }, + }); + }); + }); + + describe("IsUnknownDiscriminatedClient", () => { + const client = new IsUnknownDiscriminatedClient({ + allowInsecureConnection: true, + retryOptions: { + maxRetries: 1, + }, + }); + it("Expected response body: {'kind': 'derived', 'name': 'Derived', 'index': 314, 'age': 2.71875, 'prop1': 32, 'prop2': true, 'prop3': 'abc'}", async () => { + const response = await client.get(); + expect(response).toEqual({ + kind: "derived", + name: "Derived", + index: 314, + age: 2.71875, + additionalProperties: { prop1: 32, prop2: true, prop3: "abc" }, + }); + }); + it("Expected input body: {'kind': 'derived', 'name': 'Derived', 'index': 314, 'age': 2.71875, 'prop1': 32, 'prop2': true, 'prop3': 'abc'}", async () => { + await client.put({ + kind: "derived", + name: "Derived", + index: 314, + age: 2.71875, + additionalProperties: { + prop1: 32, + prop2: true, + prop3: "abc", + }, + } as any); + }); + }); +}); diff --git a/packages/http-client-js/test/e2e/http/type/property/additional-properties/spreads.test.ts b/packages/http-client-js/test/e2e/http/type/property/additional-properties/spreads.test.ts new file mode 100644 index 00000000000..43b25e9b6fe --- /dev/null +++ b/packages/http-client-js/test/e2e/http/type/property/additional-properties/spreads.test.ts @@ -0,0 +1,470 @@ +import { describe, expect, it } from "vitest"; +import { + ExtendsDifferentSpreadFloatClient, + ExtendsDifferentSpreadModelArrayClient, + ExtendsDifferentSpreadModelClient, + ExtendsDifferentSpreadStringClient, + ExtendsFloatClient, + ExtendsModelArrayClient, + ExtendsModelClient, + ExtendsStringClient, + IsFloatClient, + IsModelArrayClient, + IsModelClient, + IsStringClient, + MultipleSpreadClient, + SpreadDifferentFloatClient, + SpreadDifferentModelArrayClient, + SpreadDifferentModelClient, + SpreadDifferentStringClient, + SpreadFloatClient, + SpreadModelArrayClient, + SpreadModelClient, + SpreadRecordDiscriminatedUnionClient, + SpreadRecordForDiscriminatedUnion, + SpreadRecordForNonDiscriminatedUnion, + SpreadRecordForNonDiscriminatedUnion2, + SpreadRecordForNonDiscriminatedUnion3, + SpreadRecordNonDiscriminatedUnion2Client, + SpreadRecordNonDiscriminatedUnion3Client, + SpreadRecordNonDiscriminatedUnionClient, + SpreadRecordUnionClient, + SpreadStringClient, +} from "../../../../generated/type/property/additional-properties/src/index.js"; + +// Helper to create a client instance with common options. +const clientOptions = { + allowInsecureConnection: true, + retryOptions: { maxRetries: 1 }, +}; + +describe("Missing AdditionalProperties Endpoints", () => { + describe("ExtendsString", () => { + const client = new ExtendsStringClient(clientOptions); + const expected = { + additionalProperties: { prop: "abc" }, + name: "ExtendsStringAdditionalProperties", + }; + it("GET returns the expected response", async () => { + const response = await client.get(); + expect(response).toEqual(expected); + }); + it("PUT accepts the expected input", async () => { + await client.put(expected); + }); + }); + + describe("IsString", () => { + const client = new IsStringClient({ allowInsecureConnection: true }); + const expected = { + additionalProperties: { prop: "abc" }, + name: "IsStringAdditionalProperties", + }; + it("GET returns the expected response", async () => { + const response = await client.get(); + expect(response).toEqual(expected); + }); + it("PUT accepts the expected input", async () => { + await client.put(expected); + }); + }); + + describe("SpreadString", () => { + const client = new SpreadStringClient({ + allowInsecureConnection: true, + }); + const expected = { + additionalProperties: { prop: "abc" }, + name: "SpreadSpringRecord", + }; + it("GET returns the expected response", async () => { + const response = await client.get(); + expect(response).toEqual(expected); + }); + it("PUT accepts the expected input", async () => { + await client.put(expected); + }); + }); + + describe("ExtendsFloat", () => { + const client = new ExtendsFloatClient(clientOptions); + const expected = { + additionalProperties: { prop: 43.125 }, + id: 43.125, + }; + it("GET returns the expected response", async () => { + const response = await client.get(); + expect(response).toEqual(expected); + }); + it("PUT accepts the expected input", async () => { + await client.put(expected); + }); + }); + + describe("IsFloat", () => { + const client = new IsFloatClient(clientOptions); + const expected = { + additionalProperties: { prop: 43.125 }, + id: 43.125, + }; + it("GET returns the expected response", async () => { + const response = await client.get(); + expect(response).toEqual(expected); + }); + it("PUT accepts the expected input", async () => { + await client.put(expected); + }); + }); + + describe("SpreadFloat", () => { + const client = new SpreadFloatClient(clientOptions); + const expected = { + additionalProperties: { prop: 43.125 }, + id: 43.125, + }; + it("GET returns the expected response", async () => { + const response = await client.get(); + expect(response).toEqual(expected); + }); + it("PUT accepts the expected input", async () => { + await client.put(expected); + }); + }); + + describe("ExtendsModel", () => { + const client = new ExtendsModelClient(clientOptions); + const expected = { + knownProp: { state: "ok" }, + additionalProperties: { prop: { state: "ok" } }, + }; + it("GET returns the expected response", async () => { + const response = await client.get(); + expect(response).toEqual(expected); + }); + it("PUT accepts the expected input", async () => { + await client.put(expected); + }); + }); + + describe("IsModel", () => { + const client = new IsModelClient(clientOptions); + const expected = { + knownProp: { state: "ok" }, + additionalProperties: { prop: { state: "ok" } }, + }; + it("GET returns the expected response", async () => { + const response = await client.get(); + expect(response).toEqual(expected); + }); + it("PUT accepts the expected input", async () => { + await client.put(expected); + }); + }); + + describe("SpreadModel", () => { + const client = new SpreadModelClient(clientOptions); + const expected = { + knownProp: { state: "ok" }, + additionalProperties: { prop: { state: "ok" } }, + }; + it("GET returns the expected response", async () => { + const response = await client.get(); + expect(response).toEqual(expected); + }); + it("PUT accepts the expected input", async () => { + await client.put(expected); + }); + }); + + describe("ExtendsModelArray", () => { + const client = new ExtendsModelArrayClient(clientOptions); + const expected = { + knownProp: [{ state: "ok" }, { state: "ok" }], + additionalProperties: { prop: [{ state: "ok" }, { state: "ok" }] }, + }; + it("GET returns the expected response", async () => { + const response = await client.get(); + expect(response).toEqual(expected); + }); + it("PUT accepts the expected input", async () => { + await client.put(expected); + }); + }); + + describe("IsModelArray", () => { + const client = new IsModelArrayClient(clientOptions); + const expected = { + knownProp: [{ state: "ok" }, { state: "ok" }], + additionalProperties: { prop: [{ state: "ok" }, { state: "ok" }] }, + }; + it("GET returns the expected response", async () => { + const response = await client.get(); + expect(response).toEqual(expected); + }); + it("PUT accepts the expected input", async () => { + await client.put(expected); + }); + }); + + describe("SpreadModelArray", () => { + const client = new SpreadModelArrayClient(clientOptions); + const expected = { + knownProp: [{ state: "ok" }, { state: "ok" }], + additionalProperties: { prop: [{ state: "ok" }, { state: "ok" }] }, + }; + it("GET returns the expected response", async () => { + const response = await client.get(); + expect(response).toEqual(expected); + }); + it("PUT accepts the expected input", async () => { + await client.put(expected); + }); + }); + + // Known properties type is different from additional properties type + describe("SpreadDifferentStringClient", () => { + const client = new SpreadDifferentStringClient(clientOptions); + const expected = { + id: 43.125, + additionalProperties: { prop: "abc" }, + }; + it("GET returns the expected response", async () => { + const response = await client.get(); + expect(response).toEqual(expected); + }); + it("PUT accepts the expected input", async () => { + await client.put(expected); + }); + }); + + describe("SpreadDifferentFloatClient", () => { + const client = new SpreadDifferentFloatClient(clientOptions); + const expected = { + name: "abc", + additionalProperties: { prop: 43.125 }, + }; + it("GET returns the expected response", async () => { + const response = await client.get(); + expect(response).toEqual(expected); + }); + it("PUT accepts the expected input", async () => { + await client.put(expected); + }); + }); + + describe("SpreadDifferentModel", () => { + const client = new SpreadDifferentModelClient(clientOptions); + const expected = { + knownProp: "abc", + additionalProperties: { prop: { state: "ok" } }, + }; + it("GET returns the expected response", async () => { + const response = await client.get(); + expect(response).toEqual(expected); + }); + it("PUT accepts the expected input", async () => { + await client.put(expected); + }); + }); + + describe("SpreadDifferentModelArrayClient", () => { + const client = new SpreadDifferentModelArrayClient(clientOptions); + const expected = { + knownProp: "abc", + additionalProperties: { prop: [{ state: "ok" }, { state: "ok" }] }, + }; + it("GET returns the expected response", async () => { + const response = await client.get(); + expect(response).toEqual(expected); + }); + it("PUT accepts the expected input", async () => { + await client.put(expected); + }); + }); + + describe("ExtendsDifferentSpreadString", () => { + const client = new ExtendsDifferentSpreadStringClient(clientOptions); + const expected = { + id: 43.125, + additionalProperties: { prop: "abc" }, + derivedProp: "abc", + }; + it("GET returns the expected response", async () => { + const response = await client.get(); + expect(response).toEqual(expected); + }); + it("PUT accepts the expected input", async () => { + await client.put(expected); + }); + }); + + describe("ExtendsDifferentSpreadFloat", () => { + const client = new ExtendsDifferentSpreadFloatClient(clientOptions); + const expected = { + name: "abc", + additionalProperties: { prop: 43.125 }, + derivedProp: 43.125, + }; + it("GET returns the expected response", async () => { + const response = await client.get(); + expect(response).toEqual(expected); + }); + it("PUT accepts the expected input", async () => { + await client.put(expected); + }); + }); + + describe("ExtendsDifferentSpreadModel", () => { + const client = new ExtendsDifferentSpreadModelClient(clientOptions); + const expected = { + knownProp: "abc", + additionalProperties: { prop: { state: "ok" } }, + derivedProp: { state: "ok" }, + }; + it("GET returns the expected response", async () => { + const response = await client.get(); + expect(response).toEqual(expected); + }); + it("PUT accepts the expected input", async () => { + await client.put(expected); + }); + }); + + describe("ExtendsDifferentSpreadModelArray", () => { + const client = new ExtendsDifferentSpreadModelArrayClient(clientOptions); + const expected = { + knownProp: "abc", + additionalProperties: { prop: [{ state: "ok" }, { state: "ok" }] }, + derivedProp: [{ state: "ok" }, { state: "ok" }], + }; + it("GET returns the expected response", async () => { + const response = await client.get(); + expect(response).toEqual(expected); + }); + it("PUT accepts the expected input", async () => { + await client.put(expected); + }); + }); + + // Multiple spread tests + describe("MultipleSpreadRecord", () => { + const client = new MultipleSpreadClient(clientOptions); + const expected = { + flag: true, + additionalProperties: { prop1: "abc", prop2: 43.125 }, + }; + it("GET returns the expected response", async () => { + const response = await client.get(); + expect(response).toEqual(expected); + }); + it("PUT accepts the expected input", async () => { + await client.put(expected); + }); + }); + + describe("SpreadRecordUnion", () => { + const client = new SpreadRecordUnionClient(clientOptions); + const expected = { + flag: true, + additionalProperties: { prop1: "abc", prop2: 43.125 }, + }; + it("GET returns the expected response", async () => { + const response = await client.get(); + expect(response).toEqual(expected); + }); + it("PUT accepts the expected input", async () => { + await client.put(expected); + }); + }); + + describe("SpreadRecordDiscriminatedUnion", () => { + const client = new SpreadRecordDiscriminatedUnionClient(clientOptions); + const expected: SpreadRecordForDiscriminatedUnion = { + name: "abc", + additionalProperties: { + prop1: { kind: "kind0", fooProp: "abc" }, + prop2: { + kind: "kind1", + start: new Date("2021-01-01T00:00:00Z"), + end: new Date("2021-01-02T00:00:00Z"), + }, + }, + }; + it("GET returns the expected response", async () => { + const response = await client.get(); + expect(response).toEqual(expected); + }); + it("PUT accepts the expected input", async () => { + await client.put(expected); + }); + }); + + describe("SpreadRecordNonDiscriminatedUnion", () => { + const client = new SpreadRecordNonDiscriminatedUnionClient(clientOptions); + const expected: SpreadRecordForNonDiscriminatedUnion = { + name: "abc", + additionalProperties: { + prop1: { kind: "kind0", fooProp: "abc" }, + prop2: { + kind: "kind1", + start: "2021-01-01T00:00:00Z", + end: "2021-01-02T00:00:00Z", + } as any, + }, + }; + it("GET returns the expected response", async () => { + const response = await client.get(); + expect(response).toEqual(expected); + }); + it("PUT accepts the expected input", async () => { + await client.put(expected); + }); + }); + + describe("SpreadRecordNonDiscriminatedUnion2", () => { + const client = new SpreadRecordNonDiscriminatedUnion2Client(clientOptions); + const expected: SpreadRecordForNonDiscriminatedUnion2 = { + name: "abc", + additionalProperties: { + prop1: { kind: "kind1", start: "2021-01-01T00:00:00Z" }, + prop2: { + kind: "kind1", + start: "2021-01-01T00:00:00Z", + end: "2021-01-02T00:00:00Z", + } as any, + }, + }; + it("GET returns the expected response", async () => { + const response = await client.get(); + expect(response).toEqual(expected); + }); + it("PUT accepts the expected input", async () => { + await client.put(expected); + }); + }); + + describe("SpreadRecordNonDiscriminatedUnion3", () => { + const client = new SpreadRecordNonDiscriminatedUnion3Client(clientOptions); + const expected: SpreadRecordForNonDiscriminatedUnion3 = { + name: "abc", + additionalProperties: { + prop1: [ + { kind: "kind1", start: "2021-01-01T00:00:00Z" }, + { kind: "kind1", start: "2021-01-01T00:00:00Z" }, + ], + prop2: { + kind: "kind1", + start: "2021-01-01T00:00:00Z", + end: "2021-01-02T00:00:00Z", + } as any, + }, + }; + it("GET returns the expected response", async () => { + const response = await client.get(); + expect(response).toEqual(expected); + }); + it("PUT accepts the expected input", async () => { + await client.put(expected); + }); + }); +}); diff --git a/packages/http-client-js/test/e2e/http/type/property/nullable/main.test.ts b/packages/http-client-js/test/e2e/http/type/property/nullable/main.test.ts new file mode 100644 index 00000000000..5df42f046c9 --- /dev/null +++ b/packages/http-client-js/test/e2e/http/type/property/nullable/main.test.ts @@ -0,0 +1,275 @@ +import { describe, expect, it } from "vitest"; +import { + BytesClient, + CollectionsByteClient, + CollectionsModelClient, + CollectionsStringClient, + DatetimeClient, + DurationClient, + StringClient, +} from "../../../../generated/type/property/nullable/src/index.js"; + +const stringToUint8Array = (input: string): Uint8Array => { + const uint8Array = new Uint8Array(input.length); + for (let i = 0; i < input.length; i++) { + uint8Array[i] = input.charCodeAt(i); + } + return uint8Array; +}; + +const helloWorldBytes = stringToUint8Array("hello, world!"); + +describe("Type.Property.Nullable", () => { + describe("StringClient", () => { + const client = new StringClient({ allowInsecureConnection: true }); + + it("should get a model with all properties present (nullable string)", async () => { + const response = await client.getNonNull(); + expect(response).toEqual({ + requiredProperty: "foo", + nullableProperty: "hello", + }); + }); + + it("should get a model with default properties (nullable string)", async () => { + const response = await client.getNull(); + expect(response).toEqual({ + requiredProperty: "foo", + nullableProperty: null, + }); + }); + + it("should patch a model with all properties present (nullable string)", async () => { + await client.patchNonNull({ + requiredProperty: "foo", + nullableProperty: "hello", + }); + }); + + it("should patch a model with default properties (nullable string)", async () => { + await client.patchNull({ + requiredProperty: "foo", + nullableProperty: null, + }); + }); + }); + + describe("BytesClient", () => { + const client = new BytesClient({ allowInsecureConnection: true }); + + it("should get a model with all properties present (nullable bytes)", async () => { + const response = await client.getNonNull(); + expect(response).toEqual({ + requiredProperty: "foo", + nullableProperty: helloWorldBytes, + }); + }); + + it("should get a model with default properties (nullable bytes)", async () => { + const response = await client.getNull(); + expect(response).toEqual({ + requiredProperty: "foo", + nullableProperty: null, + }); + }); + + it("should patch a model with all properties present (nullable bytes)", async () => { + await client.patchNonNull({ + requiredProperty: "foo", + nullableProperty: helloWorldBytes, + }); + }); + + it("should patch a model with default properties (nullable bytes)", async () => { + await client.patchNull({ + requiredProperty: "foo", + nullableProperty: null, + }); + }); + }); + + describe("DatetimeClient", () => { + const client = new DatetimeClient({ allowInsecureConnection: true }); + + it("should get a model with all properties present (nullable datetime)", async () => { + const response = await client.getNonNull(); + expect(response).toEqual({ + requiredProperty: "foo", + nullableProperty: new Date("2022-08-26T18:38:00Z"), + }); + }); + + it("should get a model with default properties (nullable datetime)", async () => { + const response = await client.getNull(); + expect(response).toEqual({ + requiredProperty: "foo", + nullableProperty: null, + }); + }); + + it("should patch a model with all properties present (nullable datetime)", async () => { + await client.patchNonNull({ + requiredProperty: "foo", + nullableProperty: new Date("2022-08-26T18:38:00Z"), + }); + }); + + it("should patch a model with default properties (nullable datetime)", async () => { + await client.patchNull({ + requiredProperty: "foo", + nullableProperty: null, + }); + }); + }); + + describe("DurationClient", () => { + const client = new DurationClient({ allowInsecureConnection: true }); + + it("should get a model with all properties present (nullable duration)", async () => { + const response = await client.getNonNull(); + expect(response).toEqual({ + requiredProperty: "foo", + nullableProperty: "P123DT22H14M12.011S", + }); + }); + + it("should get a model with default properties (nullable duration)", async () => { + const response = await client.getNull(); + expect(response).toEqual({ + requiredProperty: "foo", + nullableProperty: null, + }); + }); + + it("should patch a model with all properties present (nullable duration)", async () => { + await client.patchNonNull({ + requiredProperty: "foo", + nullableProperty: "P123DT22H14M12.011S", + }); + }); + + it("should patch a model with default properties (nullable duration)", async () => { + await client.patchNull({ + requiredProperty: "foo", + nullableProperty: null, + }); + }); + }); + + describe("CollectionsByteClient", () => { + const client = new CollectionsByteClient({ + allowInsecureConnection: true, + retryOptions: { + maxRetries: 1, + }, + }); + + it("should get a model with all properties present (nullable collection bytes)", async () => { + const response = await client.getNonNull(); + expect(response).toEqual({ + requiredProperty: "foo", + nullableProperty: [helloWorldBytes, helloWorldBytes], + }); + }); + + it("should get a model with default properties (nullable collection bytes)", async () => { + const response = await client.getNull(); + expect(response).toEqual({ + requiredProperty: "foo", + nullableProperty: null, + }); + }); + + it("should patch a model with all properties present (nullable collection bytes)", async () => { + await client.patchNonNull({ + requiredProperty: "foo", + nullableProperty: [helloWorldBytes, helloWorldBytes], + }); + }); + + it("should patch a model with default properties (nullable collection bytes)", async () => { + await client.patchNull({ + requiredProperty: "foo", + nullableProperty: null, + }); + }); + }); + + describe("CollectionsModelClient", () => { + const client = new CollectionsModelClient({ + allowInsecureConnection: true, + retryOptions: { + maxRetries: 1, + }, + }); + + it("should get a model with all properties present (nullable collection models)", async () => { + const response = await client.getNonNull(); + expect(response).toEqual({ + requiredProperty: "foo", + nullableProperty: [{ property: "hello" }, { property: "world" }], + }); + }); + + it("should get a model with default properties (nullable collection models)", async () => { + const response = await client.getNull(); + expect(response).toEqual({ + requiredProperty: "foo", + nullableProperty: null, + }); + }); + + it("should patch a model with all properties present (nullable collection models)", async () => { + await client.patchNonNull({ + requiredProperty: "foo", + nullableProperty: [{ property: "hello" }, { property: "world" }], + }); + }); + + it("should patch a model with default properties (nullable collection models)", async () => { + await client.patchNull({ + requiredProperty: "foo", + nullableProperty: null, + }); + }); + }); + + describe("CollectionsStringClient", () => { + const client = new CollectionsStringClient({ + allowInsecureConnection: true, + retryOptions: { + maxRetries: 1, + }, + }); + + it("should get a model with all properties present (nullable collection strings)", async () => { + const response = await client.getNonNull(); + expect(response).toEqual({ + requiredProperty: "foo", + nullableProperty: ["hello", "world"], + }); + }); + + it("should get a model with default properties (nullable collection strings)", async () => { + const response = await client.getNull(); + expect(response).toEqual({ + requiredProperty: "foo", + nullableProperty: null, + }); + }); + + it("should patch a model with all properties present (nullable collection strings)", async () => { + await client.patchNonNull({ + requiredProperty: "foo", + nullableProperty: ["hello", "world"], + }); + }); + + it("should patch a model with default properties (nullable collection strings)", async () => { + await client.patchNull({ + requiredProperty: "foo", + nullableProperty: null, + }); + }); + }); +}); diff --git a/packages/http-client-js/test/e2e/http/type/property/optionality/main.test.ts b/packages/http-client-js/test/e2e/http/type/property/optionality/main.test.ts new file mode 100644 index 00000000000..15dd8f79d3d --- /dev/null +++ b/packages/http-client-js/test/e2e/http/type/property/optionality/main.test.ts @@ -0,0 +1,256 @@ +import { describe, expect, it } from "vitest"; +import { + BytesClient, + CollectionsByteClient, + CollectionsModelClient, + DatetimeClient, + DurationClient, + PlainDateClient, + PlainTimeClient, + RequiredAndOptionalClient, + StringClient, +} from "../../../../generated/type/property/optionality/src/index.js"; + +const base64EncodeToUint8Array = (input: string): Uint8Array => { + // Encode the string as Base64 + const base64String = btoa(input); + + // Decode Base64 into a binary string + const binaryString = atob(base64String); + + // Convert the binary string to a Uint8Array + const uint8Array = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + uint8Array[i] = binaryString.charCodeAt(i); + } + + return uint8Array; +}; + +const helloWorldBase64 = base64EncodeToUint8Array("hello, world!"); + +describe("Type.Property.Optional", () => { + describe("StringClient", () => { + const client = new StringClient({ allowInsecureConnection: true }); + + it("should get all string properties", async () => { + const response = await client.getAll(); + expect(response).toEqual({ property: "hello" }); + }); + + it("should get default string properties", async () => { + const response = await client.getDefault(); + expect(response).toEqual({}); + }); + + it("should put all string properties", async () => { + await client.putAll({ property: "hello" }); + }); + + it("should put default string properties", async () => { + await client.putDefault({}); + }); + }); + + describe("BytesClient", () => { + const client = new BytesClient({ allowInsecureConnection: true }); + + it("should get all bytes properties", async () => { + const response = await client.getAll(); + expect(response).toEqual({ property: helloWorldBase64 }); + }); + + it("should get default bytes properties", async () => { + const response = await client.getDefault(); + expect(response).toEqual({}); + }); + + it("should put all bytes properties", async () => { + await client.putAll({ property: helloWorldBase64 }); + }); + + it("should put default bytes properties", async () => { + await client.putDefault({}); + }); + }); + + describe("DatetimeClient", () => { + const client = new DatetimeClient({ allowInsecureConnection: true }); + + it("should get all datetime properties", async () => { + const response = await client.getAll(); + expect(response).toEqual({ property: new Date("2022-08-26T18:38:00Z") }); + }); + + it("should get default datetime properties", async () => { + const response = await client.getDefault(); + expect(response).toEqual({}); + }); + + it("should put all datetime properties", async () => { + await client.putAll({ property: new Date("2022-08-26T18:38:00Z") }); + }); + + it("should put default datetime properties", async () => { + await client.putDefault({}); + }); + }); + + describe("DurationClient", () => { + const client = new DurationClient({ allowInsecureConnection: true }); + + it("should get all duration properties", async () => { + const response = await client.getAll(); + expect(response).toEqual({ property: "P123DT22H14M12.011S" }); + }); + + it("should get default duration properties", async () => { + const response = await client.getDefault(); + expect(response).toEqual({}); + }); + + it("should put all duration properties", async () => { + await client.putAll({ property: "P123DT22H14M12.011S" }); + }); + + it("should put default duration properties", async () => { + await client.putDefault({}); + }); + }); + + describe("PlainDateClient", () => { + const client = new PlainDateClient({ allowInsecureConnection: true }); + + it("should get all plain date properties", async () => { + const response = await client.getAll(); + expect(response).toEqual({ property: "2022-12-12" }); + }); + + it("should get default plain date properties", async () => { + const response = await client.getDefault(); + expect(response).toEqual({}); + }); + + it("should put all plain date properties", async () => { + await client.putAll({ property: "2022-12-12" }); + }); + + it("should put default plain date properties", async () => { + await client.putDefault({}); + }); + }); + + describe("PlainTimeClient", () => { + const client = new PlainTimeClient({ allowInsecureConnection: true }); + + it("should get all plain time properties", async () => { + const response = await client.getAll(); + expect(response).toEqual({ property: "13:06:12" }); + }); + + it("should get default plain time properties", async () => { + const response = await client.getDefault(); + expect(response).toEqual({}); + }); + + it("should put all plain time properties", async () => { + await client.putAll({ property: "13:06:12" }); + }); + + it("should put default plain time properties", async () => { + await client.putDefault({}); + }); + }); + + describe("CollectionsByteClient", () => { + const client = new CollectionsByteClient({ + allowInsecureConnection: true, + retryOptions: { + maxRetries: 1, + }, + }); + + it("should get all collection byte properties", async () => { + const response = await client.getAll(); + expect(response).toEqual({ + property: [helloWorldBase64, helloWorldBase64], + }); + }); + + it("should get default collection byte properties", async () => { + const response = await client.getDefault(); + expect(response).toEqual({}); + }); + + it("should put all collection byte properties", async () => { + await client.putAll({ + property: [helloWorldBase64, helloWorldBase64], + }); + }); + + it("should put default collection byte properties", async () => { + await client.putDefault({}); + }); + }); + + describe("CollectionsModelClient", () => { + const client = new CollectionsModelClient({ + allowInsecureConnection: true, + retryOptions: { + maxRetries: 1, + }, + }); + + it("should get all collection model properties", async () => { + const response = await client.getAll(); + expect(response).toEqual({ + property: [{ property: "hello" }, { property: "world" }], + }); + }); + + it("should get default collection model properties", async () => { + const response = await client.getDefault(); + expect(response).toEqual({}); + }); + + it("should put all collection model properties", async () => { + await client.putAll({ + property: [{ property: "hello" }, { property: "world" }], + }); + }); + + it("should put default collection model properties", async () => { + await client.putDefault({}); + }); + }); + + describe("RequiredAndOptionalClient", () => { + const client = new RequiredAndOptionalClient({ + allowInsecureConnection: true, + retryOptions: { + maxRetries: 1, + }, + }); + + it("should get all required and optional properties", async () => { + const response = await client.getAll(); + expect(response).toEqual({ + optionalProperty: "hello", + requiredProperty: 42, + }); + }); + + it("should get only required properties", async () => { + const response = await client.getRequiredOnly(); + expect(response).toEqual({ requiredProperty: 42 }); + }); + + it("should put all required and optional properties", async () => { + await client.putAll({ optionalProperty: "hello", requiredProperty: 42 }); + }); + + it("should put only required properties", async () => { + await client.putRequiredOnly({ requiredProperty: 42 }); + }); + }); +}); diff --git a/packages/http-client-js/test/e2e/http/type/property/value-types/main.test.ts b/packages/http-client-js/test/e2e/http/type/property/value-types/main.test.ts new file mode 100644 index 00000000000..defa6bfa4ec --- /dev/null +++ b/packages/http-client-js/test/e2e/http/type/property/value-types/main.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, it } from "vitest"; +import { + BooleanClient, + BytesClient, + CollectionsStringClient, + DatetimeClient, + Decimal128Client, + DecimalClient, + DurationClient, + EnumClient, + ExtensibleEnumClient, + FixedInnerEnum, + FloatClient, + IntClient, + StringClient, +} from "../../../../generated/type/property/value-types/src/index.js"; + +const str = "hello, world!"; +const encoder = new TextEncoder(); +const helloWorldUint8Array = encoder.encode(str); + +describe("Type.Property.ValueTypes", () => { + describe("BooleanClient", () => { + const client = new BooleanClient({ allowInsecureConnection: true }); + + it("should handle a model with a boolean property", async () => { + const response = await client.get(); + expect(response).toEqual({ property: true }); + }); + + it("should send a model with a boolean property", async () => { + await client.put({ property: true }); + }); + }); + + describe("StringClient", () => { + const client = new StringClient({ allowInsecureConnection: true }); + + it("should handle a model with a string property", async () => { + const response = await client.get(); + expect(response).toEqual({ property: "hello" }); + }); + + it("should send a model with a string property", async () => { + await client.put({ property: "hello" }); + }); + }); + + describe("BytesClient", () => { + const client = new BytesClient({ + allowInsecureConnection: true, + retryOptions: { maxRetries: 1 }, + }); + + it("should handle a model with a bytes property", async () => { + const response = await client.get(); + expect(response.property).toStrictEqual(helloWorldUint8Array); + }); + + it("should send a model with a bytes property", async () => { + await client.put({ property: helloWorldUint8Array }); + }); + }); + + describe("IntClient", () => { + const client = new IntClient({ allowInsecureConnection: true }); + + it("should handle a model with an int property", async () => { + const response = await client.get(); + expect(response).toEqual({ property: 42 }); + }); + + it("should send a model with an int property", async () => { + await client.put({ property: 42 }); + }); + }); + + describe("FloatClient", () => { + const client = new FloatClient({ allowInsecureConnection: true }); + + it("should handle a model with a float property", async () => { + const response = await client.get(); + expect(response).toEqual({ property: 43.125 }); + }); + + it("should send a model with a float property", async () => { + await client.put({ property: 43.125 }); + }); + }); + + describe("DecimalClient", () => { + const client = new DecimalClient({ allowInsecureConnection: true }); + + it("should handle a model with a decimal property", async () => { + const response = await client.get(); + expect(response).toEqual({ property: 0.33333 }); + }); + + it("should send a model with a decimal property", async () => { + await client.put({ property: 0.33333 }); + }); + }); + + describe("Decimal128Client", () => { + const client = new Decimal128Client({ allowInsecureConnection: true }); + + it("should handle a model with a decimal128 property", async () => { + const response = await client.get(); + expect(response).toEqual({ property: 0.33333 }); + }); + + it("should send a model with a decimal128 property", async () => { + await client.put({ property: 0.33333 }); + }); + }); + + describe("DatetimeClient", () => { + const client = new DatetimeClient({ allowInsecureConnection: true }); + + it("should handle a model with a datetime property", async () => { + const response = await client.get(); + expect(response).toEqual({ property: new Date("2022-08-26T18:38:00Z") }); + }); + + it("should send a model with a datetime property", async () => { + await client.put({ property: new Date("2022-08-26T18:38:00Z") }); + }); + }); + + describe("DurationClient", () => { + const client = new DurationClient({ allowInsecureConnection: true }); + + it("should handle a model with a duration property", async () => { + const response = await client.get(); + expect(response).toEqual({ property: "P123DT22H14M12.011S" }); + }); + + it("should send a model with a duration property", async () => { + await client.put({ property: "P123DT22H14M12.011S" }); + }); + }); + + describe("EnumClient", () => { + const client = new EnumClient({ allowInsecureConnection: true }); + + it("should handle a model with an enum property", async () => { + const response = await client.get(); + expect(response).toEqual({ property: "ValueOne" }); + }); + + it("should send a model with an enum property", async () => { + await client.put({ property: FixedInnerEnum.ValueOne }); + }); + }); + + describe("ExtensibleEnumClient", () => { + const client = new ExtensibleEnumClient({ + allowInsecureConnection: true, + retryOptions: { + maxRetries: 1, + }, + }); + + it("should handle a model with an extensible enum property", async () => { + const response = await client.get(); + expect(response).toEqual({ property: "UnknownValue" }); + }); + + it("should send a model with an extensible enum property", async () => { + await client.put({ property: "UnknownValue" }); + }); + }); + + describe("CollectionsStringClient", () => { + const client = new CollectionsStringClient({ + allowInsecureConnection: true, + retryOptions: { + maxRetries: 1, + }, + }); + + it("should handle a model with a string collection property", async () => { + const response = await client.get(); + expect(response).toEqual({ property: ["hello", "world"] }); + }); + + it("should send a model with a string collection property", async () => { + await client.put({ property: ["hello", "world"] }); + }); + }); + + // You can add similar test cases for other clients provided in the spec. +}); diff --git a/packages/http-client-js/test/e2e/http/type/scalar/main.test.ts b/packages/http-client-js/test/e2e/http/type/scalar/main.test.ts new file mode 100644 index 00000000000..d48433b59d4 --- /dev/null +++ b/packages/http-client-js/test/e2e/http/type/scalar/main.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from "vitest"; +import { + BooleanClient, + Decimal128TypeClient, + Decimal128VerifyClient, + DecimalTypeClient, + DecimalVerifyClient, + StringClient, + UnknownClient, +} from "../../../generated/type/scalar/src/index.js"; + +describe("Type.Scalar", () => { + describe("StringClient", () => { + const client = new StringClient({ allowInsecureConnection: true }); + + it("should handle a string value returned from the server", async () => { + const response = await client.get(); + expect(response).toBe("test"); + }); + + // Mockapi plaintext issue + it.skip("should send a string value to the server", async () => { + await client.put("test"); + }); + }); + + describe("BooleanClient", () => { + const client = new BooleanClient({ allowInsecureConnection: true }); + + it("should handle a boolean value returned from the server", async () => { + const response = await client.get(); + expect(response).toBe(true); + }); + + it("should send a boolean value to the server", async () => { + await client.put(true); + }); + }); + + describe("UnknownClient", () => { + const client = new UnknownClient({ allowInsecureConnection: true }); + + it("should handle an unknown value returned from the server", async () => { + const response = await client.get(); + expect(response).toBe("test"); + }); + + // Mockapi plaintext issue + it.skip("should send an unknown value to the server", async () => { + await client.put("test"); + }); + }); + + describe("DecimalTypeClient", () => { + const client = new DecimalTypeClient({ + allowInsecureConnection: true, + retryOptions: { + maxRetries: 1, + }, + }); + + it("should handle a decimal value in the response body", async () => { + const response = await client.responseBody(); + expect(response).toBe(0.33333); + }); + + it("should send a decimal value in the request body", async () => { + await client.requestBody(0.33333); + }); + + it("should handle a decimal request parameter", async () => { + await client.requestParameter(0.33333); + }); + }); + + describe("Decimal128TypeClient", () => { + const client = new Decimal128TypeClient({ + allowInsecureConnection: true, + retryOptions: { + maxRetries: 1, + }, + }); + + it("should handle a decimal128 value in the response body", async () => { + const response = await client.responseBody(); + expect(response).toBe(0.33333); + }); + + it("should send a decimal128 value in the request body", async () => { + await client.requestBody(0.33333); + }); + + it("should handle a decimal128 request parameter", async () => { + await client.requestParameter(0.33333); + }); + }); + + describe("DecimalVerifyClient", () => { + const client = new DecimalVerifyClient({ + allowInsecureConnection: true, + retryOptions: { + maxRetries: 1, + }, + }); + + it("should prepare verify values for decimal", async () => { + const response = await client.prepareVerify(); + expect(response).toEqual([0.1, 0.1, 0.1]); + }); + + it("should send a decimal value to verify", async () => { + await client.verify(0.3); + }); + }); + + describe("Decimal128VerifyClient", () => { + const client = new Decimal128VerifyClient({ + allowInsecureConnection: true, + retryOptions: { + maxRetries: 1, + }, + }); + + it("should prepare verify values for decimal128", async () => { + const response = await client.prepareVerify(); + expect(response).toEqual([0.1, 0.1, 0.1]); + }); + + it("should send a decimal128 value to verify", async () => { + await client.verify(0.3); + }); + }); +}); diff --git a/packages/http-client-js/test/e2e/http/type/scalar/scalar.test.ts b/packages/http-client-js/test/e2e/http/type/scalar/scalar.test.ts new file mode 100644 index 00000000000..1866202b5f3 --- /dev/null +++ b/packages/http-client-js/test/e2e/http/type/scalar/scalar.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest"; +import { + BooleanClient, + DecimalTypeClient, + DecimalVerifyClient, + StringClient, + UnknownClient, +} from "../../../generated/type/scalar/src/index.js"; + +describe.skip("Type.Scalar", () => { + describe("StringClient", () => { + const client = new StringClient({ allowInsecureConnection: true }); + + it("should handle a string value returned from the server", async () => { + const response = await client.get(); + expect(response).toBe("test"); // Mock API expected value + }); + + it("should send a string value to the server", async () => { + await client.put("test"); + // Assert successful request + }); + }); + + describe("BooleanClient", () => { + const client = new BooleanClient({ allowInsecureConnection: true }); + + it("should handle a boolean value returned from the server", async () => { + const response = await client.get(); + expect(response).toBe(true); // Mock API expected value + }); + + it("should send a boolean value to the server", async () => { + await client.put(true); + // Assert successful request + }); + }); + + describe("UnknownClient", () => { + const client = new UnknownClient({ allowInsecureConnection: true }); + + it("should handle an unknown value returned from the server", async () => { + const response = await client.get(); + expect(response).toBe("test"); // Mock API expected value + }); + + it("should send an unknown value to the server", async () => { + await client.put("test"); + // Assert successful request + }); + }); + + describe("DecimalTypeClient", () => { + const client = new DecimalTypeClient({ + allowInsecureConnection: true, + retryOptions: { + maxRetries: 1, + }, + }); + + it("should handle a decimal value returned from the server", async () => { + const response = await client.responseBody(); + expect(response).toBe(0.33333); // Mock API expected value + }); + + it("should send a decimal value to the server", async () => { + await client.requestBody(0.33333); + // Assert successful request + }); + + it("should handle a decimal request parameter", async () => { + await client.requestParameter(0.33333); + // Assert successful request + }); + }); + + describe("DecimalVerifyClient", () => { + const client = new DecimalVerifyClient({ + allowInsecureConnection: true, + retryOptions: { + maxRetries: 1, + }, + }); + + it("should prepare verify values for decimal", async () => { + const response = await client.prepareVerify(); + expect(response).toEqual([0.1, 0.1, 0.1]); // Mock API expected values + }); + + it("should send a decimal value to verify", async () => { + await client.verify(0.3); + // Assert successful request + }); + }); +}); diff --git a/packages/http-client-js/test/e2e/http/type/union/main.test.ts b/packages/http-client-js/test/e2e/http/type/union/main.test.ts new file mode 100644 index 00000000000..d7e2fcd505d --- /dev/null +++ b/packages/http-client-js/test/e2e/http/type/union/main.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, it } from "vitest"; +import { + EnumsOnlyClient, + FloatsOnlyClient, + IntsOnlyClient, + Lr, + MixedLiteralsClient, + MixedTypesClient, + ModelsOnlyClient, + StringAndArrayClient, + StringExtensibleClient, + StringExtensibleNamedClient, + StringsOnlyClient, + Ud, +} from "../../../generated/type/union/src/index.js"; + +describe("Type.Union", () => { + describe("StringsOnlyClient", () => { + const client = new StringsOnlyClient({ + allowInsecureConnection: true, + retryOptions: { + maxRetries: 1, + }, + }); + + it("should handle a union of strings in response", async () => { + const response = await client.get(); + expect(response.prop).toBe("b"); // Mock API expected value + }); + + it("should send a union of strings", async () => { + await client.send("b"); + // Assert successful request + }); + }); + + describe("StringExtensibleClient", () => { + const client = new StringExtensibleClient({ + allowInsecureConnection: true, + retryOptions: { + maxRetries: 1, + }, + }); + + it("should handle an extensible string union in response", async () => { + const response = await client.get(); + expect(response.prop).toBe("custom"); // Mock API expected value + }); + + it("should send an extensible string union", async () => { + await client.send("custom"); + // Assert successful request + }); + }); + + describe("StringExtensibleNamedClient", () => { + const client = new StringExtensibleNamedClient({ + allowInsecureConnection: true, + retryOptions: { + maxRetries: 1, + }, + }); + + it("should handle an extensible named string union in response", async () => { + const response = await client.get(); + expect(response.prop).toBe("custom"); // Mock API expected value + }); + + it("should send an extensible named string union", async () => { + await client.send("custom"); + // Assert successful request + }); + }); + + describe("IntsOnlyClient", () => { + const client = new IntsOnlyClient({ allowInsecureConnection: true }); + + it("should handle a union of integers in response", async () => { + const response = await client.get(); + expect(response.prop).toBe(2); // Mock API expected value + }); + + it("should send a union of integers", async () => { + await client.send(2); + // Assert successful request + }); + }); + + describe("FloatsOnlyClient", () => { + const client = new FloatsOnlyClient({ allowInsecureConnection: true }); + + it("should handle a union of floats in response", async () => { + const response = await client.get(); + expect(response.prop).toBe(2.2); // Mock API expected value + }); + + it("should send a union of floats", async () => { + await client.send(2.2); + // Assert successful request + }); + }); + + describe("ModelsOnlyClient", () => { + const client = new ModelsOnlyClient({ allowInsecureConnection: true }); + + it("should handle a union of models in response", async () => { + const response = await client.get(); + expect(response.prop).toEqual({ name: "test" }); // Mock API expected value + }); + + it("should send a union of models", async () => { + await client.send({ name: "test" }); + // Assert successful request + }); + }); + + describe("EnumsOnlyClient", () => { + const client = new EnumsOnlyClient({ allowInsecureConnection: true }); + + it("should handle a union of enums in response", async () => { + const response = await client.get(); + expect(response.prop).toEqual({ lr: "right", ud: "up" }); // Mock API expected value + }); + + it("should send a union of enums", async () => { + await client.send({ lr: Lr.Right, ud: Ud.Up }); + // Assert successful request + }); + }); + + describe("StringAndArrayClient", () => { + const client = new StringAndArrayClient({ + allowInsecureConnection: true, + retryOptions: { + maxRetries: 1, + }, + }); + + it("should handle a union of string and array in response", async () => { + const response = await client.get(); + expect(response.prop).toEqual({ + string: "test", + array: ["test1", "test2"], + }); // Mock API expected value + }); + + it("should send a union of string and array", async () => { + await client.send({ string: "test", array: ["test1", "test2"] }); + // Assert successful request + }); + }); + + describe("MixedLiteralsClient", () => { + const client = new MixedLiteralsClient({ + allowInsecureConnection: true, + retryOptions: { + maxRetries: 1, + }, + }); + + it("should handle a union of mixed literals in response", async () => { + const response = await client.get(); + expect(response.prop).toEqual({ + stringLiteral: "a", + intLiteral: 2, + floatLiteral: 3.3, + booleanLiteral: true, + }); // Mock API expected value + }); + + it("should send a union of mixed literals", async () => { + await client.send({ + stringLiteral: "a", + intLiteral: 2, + floatLiteral: 3.3, + booleanLiteral: true, + }); + // Assert successful request + }); + }); + + describe("MixedTypesClient", () => { + const client = new MixedTypesClient({ allowInsecureConnection: true }); + + it("should handle a union of mixed types in response", async () => { + const response = await client.get(); + expect(response.prop).toEqual({ + model: { name: "test" }, + literal: "a", + int: 2, + boolean: true, + array: [{ name: "test" }, "a", 2, true], + }); // Mock API expected value + }); + + it("should send a union of mixed types", async () => { + await client.send({ + model: { name: "test" }, + literal: "a", + int: 2, + boolean: true, + array: [{ name: "test" }, "a", 2, true], + }); + // Assert successful request + }); + }); +}); diff --git a/packages/http-client-js/test/e2e/http/versioning/added/main.test.ts b/packages/http-client-js/test/e2e/http/versioning/added/main.test.ts new file mode 100644 index 00000000000..b73b3a565f2 --- /dev/null +++ b/packages/http-client-js/test/e2e/http/versioning/added/main.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { + AddedClient, + EnumV1, + EnumV2, + InterfaceV2Client, + ModelV1, + ModelV2, + Versions, +} from "../../../generated/versioning/added/src/index.js"; + +describe("Versioning.Added", () => { + const client = new AddedClient("http://localhost:3000", Versions.V2, { + allowInsecureConnection: true, + retryOptions: { + maxRetries: 0, + }, + }); + + // Issue in spec op v1(@body body: ModelV1, @added(Versions.v2) @header headerV2: string): ModelV1; + // Mock expects header-v2 as the name. + it.skip("should send and receive v1 operation with ModelV1 at latest version", async () => { + const body: ModelV1 = { + prop: "foo", + unionProp: 10, + enumProp: EnumV1.EnumMemberV2, + }; + + const response = await client.v1(body, "bar"); + expect(response).toEqual(body); + }); + + it("should send and receive v2 operation with ModelV2 at latest version", async () => { + const body: ModelV2 = { + prop: "foo", + enumProp: EnumV2.EnumMember, + unionProp: "bar", + }; + const response = await client.v2(body); + expect(response).toEqual(body); + }); + + describe("InterfaceV2Client", () => { + const interfaceV2Client = new InterfaceV2Client("http://localhost:3000", Versions.V2, { + allowInsecureConnection: true, + retryOptions: { + maxRetries: 1, + }, + }); + + it("should send and receive v2InInterface operation with ModelV2", async () => { + const body: ModelV2 = { + prop: "foo", + enumProp: EnumV2.EnumMember, + unionProp: "bar", + }; + const response = await interfaceV2Client.v2InInterface(body); + expect(response).toEqual(body); + }); + }); +}); diff --git a/packages/http-client-js/test/e2e/http/versioning/madeOptional/main.test.ts b/packages/http-client-js/test/e2e/http/versioning/madeOptional/main.test.ts new file mode 100644 index 00000000000..2aabb757b31 --- /dev/null +++ b/packages/http-client-js/test/e2e/http/versioning/madeOptional/main.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { + MadeOptionalClient, + Versions, +} from "../../../generated/versioning/madeOptional/src/index.js"; + +describe("Versioning.MadeOptional", () => { + const endpoint = "http://localhost:3000"; + + describe("v1", () => { + const client = new MadeOptionalClient(endpoint, Versions.V1); + + it("should send the request body and handle the response for v1", async () => { + const requestBody = { prop: "foo" }; + + const response = await client.test(requestBody); + + expect(response).toEqual(requestBody); // Mock API expected value + }); + }); + + describe("v2", () => { + const client = new MadeOptionalClient(endpoint, Versions.V2); + + it("should send the request body, additional query param, and handle the response for v2", async () => { + const requestBody = { prop: "foo" }; + const queryParam = "exampleParam"; + + const response = await client.test(requestBody, { param: queryParam }); + + expect(response).toEqual(requestBody); // Mock API expected value + }); + }); +}); diff --git a/packages/http-client-js/test/e2e/http/versioning/removed/main.test.ts b/packages/http-client-js/test/e2e/http/versioning/removed/main.test.ts new file mode 100644 index 00000000000..309aa31515a --- /dev/null +++ b/packages/http-client-js/test/e2e/http/versioning/removed/main.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import { + EnumV2, + EnumV3, + ModelV3, + RemovedClient, + Versions, +} from "../../../generated/versioning/removed/src/index.js"; + +describe("Versioning.Removed", () => { + const client = new RemovedClient("http://localhost:3000", Versions.V2, { + allowInsecureConnection: true, + }); + + describe("v2 operation", () => { + it("should send and receive ModelV2 with the correct signature", async () => { + const body = { + prop: "foo", + enumProp: EnumV2.EnumMemberV2, + unionProp: "bar", + }; + const response = await client.v2(body as any, ""); + expect(response).toEqual(body); // Mock API expected value + }); + }); + + describe("modelV3 operation", () => { + it("should handle ModelV3 for v1", async () => { + const client = new RemovedClient("http://localhost:3000", Versions.V1, { + allowInsecureConnection: true, + retryOptions: { + maxRetries: 1, + }, + }); + const body: ModelV3 = { + id: "123", + enumProp: EnumV3.EnumMemberV1, + }; + const response = await client.modelV3(body); + expect(response).toEqual(body); // Mock API expected value + }); + + it.skip("should handle ModelV3 for v2preview", async () => { + const client = new RemovedClient("http://localhost:3000", Versions.V2preview, { + allowInsecureConnection: true, + retryOptions: { + maxRetries: 1, + }, + }); + + const body = { + id: "123", + }; + // Support versioning projections.s + const response = await client.modelV3(body as any); + expect(response).toEqual(body); // Mock API expected value + }); + + it("should handle ModelV3 for v2", async () => { + const client = new RemovedClient("http://localhost:3000", Versions.V2, { + allowInsecureConnection: true, + retryOptions: { + maxRetries: 1, + }, + }); + + const body = { + id: "123", + enumProp: EnumV3.EnumMemberV1, + }; + const response = await client.modelV3(body); + expect(response).toEqual(body); // Mock API expected value + }); + }); +}); diff --git a/packages/http-client-js/test/e2e/http/versioning/renamedFrom/main.test.ts b/packages/http-client-js/test/e2e/http/versioning/renamedFrom/main.test.ts new file mode 100644 index 00000000000..67780e6cbec --- /dev/null +++ b/packages/http-client-js/test/e2e/http/versioning/renamedFrom/main.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { + NewEnum, + NewInterfaceClient, + RenamedFromClient, + Versions, +} from "../../../generated/versioning/renamedFrom/src/index.js"; + +describe("Versioning.RenamedFrom", () => { + describe("RenamedFromClient", () => { + const client = new RenamedFromClient("http://localhost:3000", Versions.V2, { + allowInsecureConnection: true, + retryOptions: { + maxRetries: 1, + }, + }); + + it("should handle 'newOp' with renamed properties and return the expected response", async () => { + const body = { + enumProp: NewEnum.NewEnumMember, + newProp: "foo", + unionProp: 10, + }; + + const response = await client.newOp(body, "bar"); + expect(response).toEqual(body); // Mock API expected to return the same body + }); + }); + + describe("NewInterfaceClient", () => { + const client = new NewInterfaceClient("http://localhost:3000", Versions.V2, { + allowInsecureConnection: true, + retryOptions: { + maxRetries: 1, + }, + }); + + it("should handle 'newOpInNewInterface' with renamed properties and return the expected response", async () => { + const body = { + newProp: "foo", + enumProp: "newEnumMember", + unionProp: 10, + }; + const response = await client.newOpInNewInterface({ + enumProp: NewEnum.NewEnumMember, + newProp: "foo", + unionProp: 10, + }); + expect(response).toEqual(body); // Mock API expected to return the same body + }); + }); +}); diff --git a/packages/http-client-js/test/e2e/http/versioning/returnTypeChangedFrom/main.test.ts b/packages/http-client-js/test/e2e/http/versioning/returnTypeChangedFrom/main.test.ts new file mode 100644 index 00000000000..ee97f976ab2 --- /dev/null +++ b/packages/http-client-js/test/e2e/http/versioning/returnTypeChangedFrom/main.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { + ReturnTypeChangedFromClient, + Versions, +} from "../../../generated/versioning/returnTypeChangedFrom/src/index.js"; + +// Issue with mockapi expecting plain text +describe.skip("Versioning.ReturnTypeChangedFrom", () => { + describe("TestClient", () => { + const client = new ReturnTypeChangedFromClient("http://localhost:3000", Versions.V2, { + allowInsecureConnection: true, + retryOptions: { + maxRetries: 1, + }, + }); + + it("should send the request body and return the expected response body for version 'v2'", async () => { + const response = await client.test("test"); + expect(response).toBe("test"); // Mock API expected value + }); + }); +}); diff --git a/packages/http-client-js/test/e2e/http/versioning/typeChangedFrom/main.test.ts b/packages/http-client-js/test/e2e/http/versioning/typeChangedFrom/main.test.ts new file mode 100644 index 00000000000..3aee9eacc2c --- /dev/null +++ b/packages/http-client-js/test/e2e/http/versioning/typeChangedFrom/main.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { + TypeChangedFromClient, + Versions, +} from "../../../generated/versioning/typeChangedFrom/src/index.js"; + +describe("Versioning.TypeChangedFrom", () => { + const endpoint = "http://localhost:3000"; + + describe("v1", () => { + const client = new TypeChangedFromClient(endpoint, Versions.V2); + + it("should send and receive data using v1 API signature", async () => { + const requestBody = { prop: "foo", changedProp: "bar" }; + const response = await client.test(requestBody, "baz"); + expect(response).toEqual(requestBody); // Mock API behavior + }); + }); + + describe("v2", () => { + const client = new TypeChangedFromClient(endpoint, Versions.V2); + + it("should send and receive data using v2 API signature", async () => { + const requestBody = { prop: "foo", changedProp: "bar" }; + const queryParam = "baz"; + + const response = await client.test(requestBody, queryParam); + expect(response).toEqual(requestBody); // Mock API behavior + }); + }); +}); diff --git a/packages/http-client-js/test/scenarios.test.ts b/packages/http-client-js/test/scenarios.test.ts new file mode 100644 index 00000000000..98f1aae93e7 --- /dev/null +++ b/packages/http-client-js/test/scenarios.test.ts @@ -0,0 +1,18 @@ +import { + createSnipperExtractor, + createTypeScriptExtractorConfig, + executeScenarios, +} from "@typespec/emitter-framework/testing"; +import { join } from "path"; +import { HttpClientJavascriptEmitterTestLibrary } from "../src/testing/index.js"; + +const tsExtractorConfig = createTypeScriptExtractorConfig(); +const snipperExtractor = createSnipperExtractor(tsExtractorConfig); + +await executeScenarios( + HttpClientJavascriptEmitterTestLibrary, + tsExtractorConfig, + "./test/scenarios", + join("tsp-output", "@typespec/http-client-js"), + snipperExtractor, +); diff --git a/packages/http-client-js/test/scenarios/additional-properties/extends.md b/packages/http-client-js/test/scenarios/additional-properties/extends.md new file mode 100644 index 00000000000..500a0b38b1e --- /dev/null +++ b/packages/http-client-js/test/scenarios/additional-properties/extends.md @@ -0,0 +1,71 @@ +# Should generate a model with an additional properties defined as extending a Record + +## TypeSpec + +Defines a model with Additional Properties modeled as externding a Record + +```tsp +namespace Test; + +model Widget extends Record { + name: string; + age: int32; + optional?: string; +} +op foo(): Widget; +``` + +## Models + +Should generate a model with name `Widget` with the known properties in the root and an evelop property called `additionalProperties` + +```ts src/models/models.ts interface Widget +export interface Widget { + name: string; + age: number; + optional?: string; + additionalProperties?: Record; +} +``` + +## Serializer + +The deserializer flattens the additional property envelope into the root of the payload + +```ts src/models/serializers.ts function jsonWidgetToTransportTransform +export function jsonWidgetToTransportTransform(input_?: Widget | null): any { + if (!input_) { + return input_ as any; + } + + return { + ...jsonRecordUnknownToTransportTransform(input_.additionalProperties), + + name: input_.name, + age: input_.age, + optional: input_.optional, + }!; +} +``` + +## Deserializer + +Deserializer puts the known properties in the root of the object and creates an additional Properties envelope. + +```ts src/models/serializers.ts function jsonWidgetToApplicationTransform +export function jsonWidgetToApplicationTransform(input_?: any): Widget { + if (!input_) { + return input_ as any; + } + + return { + additionalProperties: jsonRecordUnknownToApplicationTransform( + (({ name, age, optional, ...rest }) => rest)(input_), + ), + + name: input_.name, + age: input_.age, + optional: input_.optional, + }!; +} +``` diff --git a/packages/http-client-js/test/scenarios/additional-properties/is.md b/packages/http-client-js/test/scenarios/additional-properties/is.md new file mode 100644 index 00000000000..f0c523bcebb --- /dev/null +++ b/packages/http-client-js/test/scenarios/additional-properties/is.md @@ -0,0 +1,46 @@ +# Should generate a model with an additional properties defined with model is Record<> + +## TypeSpec + +Defines a model with Additional Properties modeled as model is Record<> + +```tsp +namespace Test; + +model Widget is Record; + +op foo(): Widget; +``` + +## Models + +Should not create model and treat it as a Record. + +## Operation + +Should just treat it as a Record + +```ts src/api/testClientOperations.ts function foo +export async function foo( + client: TestClientContext, + options?: FooOptions, +): Promise> { + const path = parse("/").expand({}); + + const httpRequestOptions = { + headers: {}, + }; + + const response = await client.pathUnchecked(path).get(httpRequestOptions); + + if (typeof options?.operationOptions?.onResponse === "function") { + options?.operationOptions?.onResponse(response); + } + + if (+response.status === 200 && response.headers["content-type"]?.includes("application/json")) { + return jsonRecordStringToApplicationTransform(response.body)!; + } + + throw createRestError(response); +} +``` diff --git a/packages/http-client-js/test/scenarios/additional-properties/spread.md b/packages/http-client-js/test/scenarios/additional-properties/spread.md new file mode 100644 index 00000000000..756b0841ee0 --- /dev/null +++ b/packages/http-client-js/test/scenarios/additional-properties/spread.md @@ -0,0 +1,72 @@ +# Should generate a model with an additional properties defined as spread + +## TypeSpec + +Defines a model with Additional Properties modeled as spreading a Record + +```tsp +namespace Test; + +model Widget { + name: string; + age: int32; + optional?: string; + ...Record; +} +op foo(): Widget; +``` + +## Models + +Should generate a model with name `Widget` with the known properties in the root and an evelop property called `additionalProperties` + +```ts src/models/models.ts interface Widget +export interface Widget { + name: string; + age: number; + optional?: string; + additionalProperties?: Record; +} +``` + +## Serializer + +The deserializer flattens the additional property envelope into the root of the payload + +```ts src/models/serializers.ts function jsonWidgetToTransportTransform +export function jsonWidgetToTransportTransform(input_?: Widget | null): any { + if (!input_) { + return input_ as any; + } + + return { + ...jsonRecordStringToTransportTransform(input_.additionalProperties), + + name: input_.name, + age: input_.age, + optional: input_.optional, + }!; +} +``` + +## Deserializer + +Deserializer puts the known properties in the root of the object and creates an additional Properties envelope. + +```ts src/models/serializers.ts function jsonWidgetToApplicationTransform +export function jsonWidgetToApplicationTransform(input_?: any): Widget { + if (!input_) { + return input_ as any; + } + + return { + additionalProperties: jsonRecordStringToApplicationTransform( + (({ name, age, optional, ...rest }) => rest)(input_), + ), + + name: input_.name, + age: input_.age, + optional: input_.optional, + }!; +} +``` diff --git a/packages/http-client-js/test/scenarios/auth/basic_auth.md b/packages/http-client-js/test/scenarios/auth/basic_auth.md new file mode 100644 index 00000000000..5605c57e9ae --- /dev/null +++ b/packages/http-client-js/test/scenarios/auth/basic_auth.md @@ -0,0 +1,67 @@ +# Handles a basic authentication scheme + +This test validates that the emitter can handle a basic authentication scheme correctly, including the client signature and its initialization + +## Typespec + +The spec contains a simple service with a Bearer authentication scheme + +```tsp +@service({ + title: "Test Service", +}) +@useAuth(BasicAuth) +namespace Test; + +@route("/valid") +@get +op valid(): NoContentResponse; +``` + +## TypeScript + +### Client + +The client signature should include a positional parameter for credential of type KeyCredential. A basic auth token is a key credential that gets put into the Authorization header + +```ts src/testClient.ts class TestClient +export class TestClient { + #context: TestClientContext; + + constructor(endpoint: string, credential: KeyCredential, options?: TestClientOptions) { + this.#context = createTestClientContext(endpoint, credential, options); + } + async valid(options?: ValidOptions) { + return valid(this.#context, options); + } +} +``` + +### ClientContext + +The client context should setup the pipeline to use the credential in the Authorization header. + +```ts src/api/testClientContext.ts function createTestClientContext +export function createTestClientContext( + endpoint: string, + credential: KeyCredential, + options?: TestClientOptions, +): TestClientContext { + const params: Record = { + endpoint: endpoint, + }; + const resolvedEndpoint = "{endpoint}".replace(/{([^}]+)}/g, (_, key) => + key in params + ? String(params[key]) + : (() => { + throw new Error(`Missing parameter: ${key}`); + })(), + ); + return getClient(resolvedEndpoint, credential, { + ...options, + credentials: { + apiKeyHeaderName: "Authorization", + }, + }); +} +``` diff --git a/packages/http-client-js/test/scenarios/auth/bearer.md b/packages/http-client-js/test/scenarios/auth/bearer.md new file mode 100644 index 00000000000..f3214ba9cc4 --- /dev/null +++ b/packages/http-client-js/test/scenarios/auth/bearer.md @@ -0,0 +1,67 @@ +# Handles a simple bearer authentication scheme + +This test validates that the emitter can handle a simple bearer authentication scheme correctly, including the client signature and its initialization + +## Typespec + +The spec contains a simple service with a Bearer authentication scheme + +```tsp +@service({ + title: "Test Service", +}) +@useAuth(BearerAuth) +namespace Test; + +@route("/valid") +@get +op valid(): NoContentResponse; +``` + +## TypeScript + +### Client + +The client signature should include a positional parameter for credential of type KeyCredential. A Bearer token is a key credential that gets put into the Authorization header + +```ts src/testClient.ts class TestClient +export class TestClient { + #context: TestClientContext; + + constructor(endpoint: string, credential: KeyCredential, options?: TestClientOptions) { + this.#context = createTestClientContext(endpoint, credential, options); + } + async valid(options?: ValidOptions) { + return valid(this.#context, options); + } +} +``` + +### ClientContext + +The client context should setup the pipeline to use the credential in the Authorization header. + +```ts src/api/testClientContext.ts function createTestClientContext +export function createTestClientContext( + endpoint: string, + credential: KeyCredential, + options?: TestClientOptions, +): TestClientContext { + const params: Record = { + endpoint: endpoint, + }; + const resolvedEndpoint = "{endpoint}".replace(/{([^}]+)}/g, (_, key) => + key in params + ? String(params[key]) + : (() => { + throw new Error(`Missing parameter: ${key}`); + })(), + ); + return getClient(resolvedEndpoint, credential, { + ...options, + credentials: { + apiKeyHeaderName: "Authorization", + }, + }); +} +``` diff --git a/packages/http-client-js/test/scenarios/auth/client_parameters.md b/packages/http-client-js/test/scenarios/auth/client_parameters.md new file mode 100644 index 00000000000..cbd68beecc0 --- /dev/null +++ b/packages/http-client-js/test/scenarios/auth/client_parameters.md @@ -0,0 +1,68 @@ +# Handle multiple auth schemas + +This test validates that the emitter can generate the correct client signature when more than one schema is used. + +## Typespec + +The spec contains 2 Schemas Bearer and ApiKey + +```tsp +@service({ + title: "Test Service", +}) +@useAuth(BearerAuth | ApiKeyAuth) +namespace Test; + +@route("/valid") +@get +op valid(): NoContentResponse; +``` + +## TypeScript + +### Client + +The client signature should include a positional parameter for credential of type KeyCredential. Internally both translate to the same type KeyCredential. +TODO: Revisit if we need additional types since it will be difficult at runtime to differentiate + +```ts src/testClient.ts class TestClient +export class TestClient { + #context: TestClientContext; + + constructor(endpoint: string, credential: KeyCredential, options?: TestClientOptions) { + this.#context = createTestClientContext(endpoint, credential, options); + } + async valid(options?: ValidOptions) { + return valid(this.#context, options); + } +} +``` + +### ClientContext + +The client context should setup the pipeline to use the credential in the Authorization header. + +```ts src/api/testClientContext.ts function createTestClientContext +export function createTestClientContext( + endpoint: string, + credential: KeyCredential, + options?: TestClientOptions, +): TestClientContext { + const params: Record = { + endpoint: endpoint, + }; + const resolvedEndpoint = "{endpoint}".replace(/{([^}]+)}/g, (_, key) => + key in params + ? String(params[key]) + : (() => { + throw new Error(`Missing parameter: ${key}`); + })(), + ); + return getClient(resolvedEndpoint, credential, { + ...options, + credentials: { + apiKeyHeaderName: "Authorization", + }, + }); +} +``` diff --git a/packages/http-client-js/test/scenarios/auth/client_structure.md b/packages/http-client-js/test/scenarios/auth/client_structure.md new file mode 100644 index 00000000000..04e560b83bd --- /dev/null +++ b/packages/http-client-js/test/scenarios/auth/client_structure.md @@ -0,0 +1,38 @@ +# The presence of @useAuth decorator shouldn't impact the client hierarchy + +## TypeSpec + +```tsp +@service({ + title: "My API", +}) +@useAuth(BearerAuth) +namespace MyApi; + +@route("/foo") +namespace Foo { + @get op getfoo(): string; +} + +@route("/bar") +namespace Bar { + @get op getBar(): string; +} +``` + +## TypeScript + +The client structure should be MyApi class with no operations and 2 members, BarClient and FooClient. + +```ts src/myApiClient.ts class MyApiClient +export class MyApiClient { + #context: MyApiClientContext; + fooClient: FooClient; + barClient: BarClient; + constructor(endpoint: string, credential: KeyCredential, options?: MyApiClientOptions) { + this.#context = createMyApiClientContext(endpoint, credential, options); + this.fooClient = new FooClient(endpoint, credential, options); + this.barClient = new BarClient(endpoint, credential, options); + } +} +``` diff --git a/packages/http-client-js/test/scenarios/auth/key_credential.md b/packages/http-client-js/test/scenarios/auth/key_credential.md new file mode 100644 index 00000000000..f6e8f65b892 --- /dev/null +++ b/packages/http-client-js/test/scenarios/auth/key_credential.md @@ -0,0 +1,67 @@ +# Handles a simple bearer authentication scheme + +This test validates that the emitter can handle a key auth scheme correctly with a header location + +## Typespec + +The spec contains a simple service with a ApiKey authentication scheme + +```tsp +@service({ + title: "Test Service", +}) +@useAuth(ApiKeyAuth) +namespace Test; + +@route("/valid") +@get +op valid(): NoContentResponse; +``` + +## TypeScript + +### Client + +The client signature should include a positional parameter for credential of type KeyCredential. + +```ts src/testClient.ts class TestClient +export class TestClient { + #context: TestClientContext; + + constructor(endpoint: string, credential: KeyCredential, options?: TestClientOptions) { + this.#context = createTestClientContext(endpoint, credential, options); + } + async valid(options?: ValidOptions) { + return valid(this.#context, options); + } +} +``` + +### ClientContext + +The client context should setup the pipeline to use the credential in the header with the name provided in the Scheme configuration. In this case "X-API-KEY" + +```ts src/api/testClientContext.ts function createTestClientContext +export function createTestClientContext( + endpoint: string, + credential: KeyCredential, + options?: TestClientOptions, +): TestClientContext { + const params: Record = { + endpoint: endpoint, + }; + const resolvedEndpoint = "{endpoint}".replace(/{([^}]+)}/g, (_, key) => + key in params + ? String(params[key]) + : (() => { + throw new Error(`Missing parameter: ${key}`); + })(), + ); + return getClient(resolvedEndpoint, credential, { + ...options, + credentials: { + apiKeyHeaderName: "X-API-KEY", + }, + }); +} +``` diff --git a/packages/http-client-js/test/scenarios/auth/sub_client_override.md b/packages/http-client-js/test/scenarios/auth/sub_client_override.md new file mode 100644 index 00000000000..c32c4ec7213 --- /dev/null +++ b/packages/http-client-js/test/scenarios/auth/sub_client_override.md @@ -0,0 +1,114 @@ +# A sub client can override the partent credential scheme + +The sub client sets NoAuth to override the auth scheme of the parent + +## Typespec + +The spec contains a client with BasicAuth and a sub client with no auth + +```tsp +@service({ + title: "Test Service", +}) +@useAuth(BasicAuth) +namespace Test; + +@route("/") +@get +op valid(): NoContentResponse; + +@useAuth(NoAuth) +@route("/sub") +namespace Sub { + @get + op put(): NoContentResponse; +} +``` + +## TypeScript + +### Client + +The client signature should include a positional parameter for credential of type KeyCredential. A basic auth token is a key credential that gets put into the Authorization header/ + +The subclient is not a child of the TestClient because they have different parameter. + +```ts src/testClient.ts class TestClient +export class TestClient { + #context: TestClientContext; + + constructor(endpoint: string, credential: KeyCredential, options?: TestClientOptions) { + this.#context = createTestClientContext(endpoint, credential, options); + } + async valid(options?: ValidOptions) { + return valid(this.#context, options); + } +} +``` + +The sub client shouldn't take a credential + +```ts src/testClient.ts class SubClient +export class SubClient { + #context: SubClientContext; + + constructor(endpoint: string, options?: SubClientOptions) { + this.#context = createSubClientContext(endpoint, options); + } + async put(options?: PutOptions) { + return put(this.#context, options); + } +} +``` + +### ClientContext + +The client context should setup the pipeline to use the credential in the Authorization header. + +```ts src/api/testClientContext.ts function createTestClientContext +export function createTestClientContext( + endpoint: string, + credential: KeyCredential, + options?: TestClientOptions, +): TestClientContext { + const params: Record = { + endpoint: endpoint, + }; + const resolvedEndpoint = "{endpoint}".replace(/{([^}]+)}/g, (_, key) => + key in params + ? String(params[key]) + : (() => { + throw new Error(`Missing parameter: ${key}`); + })(), + ); + return getClient(resolvedEndpoint, credential, { + ...options, + credentials: { + apiKeyHeaderName: "Authorization", + }, + }); +} +``` + +The suv client context should setup the pipeline to use the credential in the Authorization header. + +```ts src/api/subClient/subClientContext.ts function createSubClientContext +export function createSubClientContext( + endpoint: string, + options?: SubClientOptions, +): SubClientContext { + const params: Record = { + endpoint: endpoint, + }; + const resolvedEndpoint = "{endpoint}".replace(/{([^}]+)}/g, (_, key) => + key in params + ? String(params[key]) + : (() => { + throw new Error(`Missing parameter: ${key}`); + })(), + ); + return getClient(resolvedEndpoint, credential, { + ...options, + }); +} +``` diff --git a/packages/http-client-js/test/scenarios/client/client_context.md b/packages/http-client-js/test/scenarios/client/client_context.md new file mode 100644 index 00000000000..d5a65105749 --- /dev/null +++ b/packages/http-client-js/test/scenarios/client/client_context.md @@ -0,0 +1,36 @@ +# Should generate a client context factory for a simple client + +## TypeSpec + +```tsp +@service({ + title: "Widget Service", +}) +namespace DemoService; +op foo(): void; +``` + +## TypeScript + +Should generate a factory function named after the namespace `createDemoServiceContext`. Since there is no url defined the factory takes an endpoint parameter + +```ts src/api/demoServiceClientContext.ts function createDemoServiceClientContext +export function createDemoServiceClientContext( + endpoint: string, + options?: DemoServiceClientOptions, +): DemoServiceClientContext { + const params: Record = { + endpoint: endpoint, + }; + const resolvedEndpoint = "{endpoint}".replace(/{([^}]+)}/g, (_, key) => + key in params + ? String(params[key]) + : (() => { + throw new Error(`Missing parameter: ${key}`); + })(), + ); + return getClient(resolvedEndpoint, { + ...options, + }); +} +``` diff --git a/packages/http-client-js/test/scenarios/client/dotted_namespace.md b/packages/http-client-js/test/scenarios/client/dotted_namespace.md new file mode 100644 index 00000000000..6de44fc9508 --- /dev/null +++ b/packages/http-client-js/test/scenarios/client/dotted_namespace.md @@ -0,0 +1,30 @@ +# Handles dotted namespaces when only the last has content + +## Spec + +A dotted namespace where only the last part has content. The leading namespaces have no operations and a single sub namespace + +```tsp +@service({ + title: "TestService", +}) +namespace Foo.Bar.Baz; +@get op get(): string[]; +``` + +## Expectations + +The client should match the last namespace. + +```ts src/bazClient.ts class BazClient +export class BazClient { + #context: BazClientContext; + + constructor(endpoint: string, options?: BazClientOptions) { + this.#context = createBazClientContext(endpoint, options); + } + async get(options?: GetOptions) { + return get(this.#context, options); + } +} +``` diff --git a/packages/http-client-js/test/scenarios/client/global_namespace.md b/packages/http-client-js/test/scenarios/client/global_namespace.md new file mode 100644 index 00000000000..9346fb8bf36 --- /dev/null +++ b/packages/http-client-js/test/scenarios/client/global_namespace.md @@ -0,0 +1,74 @@ +# Client from the global namespace + +This tests that the emitter can handle a spec that has no top-level namespace defined + +## TypeSpec + +```tsp +op foo(): void; +``` + +## TypeScript + +It should generate an interface and factory for the client context. + +```ts src/api/clientContext.ts interface ClientContext +export interface ClientContext extends Client {} +``` + +```ts src/api/clientContext.ts function createClientContext +export function createClientContext(endpoint: string, options?: ClientOptions): ClientContext { + const params: Record = { + endpoint: endpoint, + }; + const resolvedEndpoint = "{endpoint}".replace(/{([^}]+)}/g, (_, key) => + key in params + ? String(params[key]) + : (() => { + throw new Error(`Missing parameter: ${key}`); + })(), + ); + return getClient(resolvedEndpoint, { + ...options, + }); +} +``` + +It should generate a client for the Global Namespace with a single operation `foo` matching the spec and no sub-clients. + +```ts src/client.ts class Client +export class Client { + #context: ClientContext; + + constructor(endpoint: string, options?: ClientOptions) { + this.#context = createClientContext(endpoint, options); + } + async foo(options?: FooOptions) { + return foo(this.#context, options); + } +} +``` + +It should generate an operation for foo + +```ts src/api/clientOperations.ts function foo +export async function foo(client: ClientContext, options?: FooOptions): Promise { + const path = parse("/").expand({}); + + const httpRequestOptions = { + headers: {}, + }; + + const response = await client.pathUnchecked(path).get(httpRequestOptions); + + if (typeof options?.operationOptions?.onResponse === "function") { + options?.operationOptions?.onResponse(response); + } + + if (+response.status === 204 && !response.body) { + return; + } + + throw createRestError(response); +} +``` diff --git a/packages/http-client-js/test/scenarios/client/multiple_top_level_clients.md b/packages/http-client-js/test/scenarios/client/multiple_top_level_clients.md new file mode 100644 index 00000000000..26b36316bf9 --- /dev/null +++ b/packages/http-client-js/test/scenarios/client/multiple_top_level_clients.md @@ -0,0 +1,74 @@ +# Emits multiple top level clients + +Verifies that the emitter can handle correctly when there are 2 root namespaces, translating these into 2 separate clients + +## TypeSpec + +```tsp +namespace Foo { + model FooItem { + name: string; + } + + op get(): FooItem; +} + +namespace Bar { + model BarItem { + title: string; + } + + op get(): BarItem; + @post op create(foo: BarItem): void; +} +``` + +## TypeScript + +It should generate a client for Foo with a single operation as defined in the spec + +```ts src/fooClient.ts +import { GetOptions, get } from "./api/fooClientOperations.js"; +import { + FooClientContext, + FooClientOptions, + createFooClientContext, +} from "./api/fooClientContext.js"; + +export class FooClient { + #context: FooClientContext; + + constructor(endpoint: string, options?: FooClientOptions) { + this.#context = createFooClientContext(endpoint, options); + } + async get(options?: GetOptions) { + return get(this.#context, options); + } +} +``` + +It should generate a client for Bar with `create` and `get` operations as defined in the spec + +```ts src/barClient.ts +import { BarItem } from "./models/models.js"; +import { GetOptions, get, CreateOptions, create } from "./api/barClientOperations.js"; +import { + BarClientContext, + BarClientOptions, + createBarClientContext, +} from "./api/barClientContext.js"; + +export class BarClient { + #context: BarClientContext; + + constructor(endpoint: string, options?: BarClientOptions) { + this.#context = createBarClientContext(endpoint, options); + } + async get(options?: GetOptions) { + return get(this.#context, options); + } + async create(foo: BarItem, options?: CreateOptions) { + return create(this.#context, foo, options); + } +} +``` diff --git a/packages/http-client-js/test/scenarios/client/nested_client.md b/packages/http-client-js/test/scenarios/client/nested_client.md new file mode 100644 index 00000000000..e4a30d7d5d5 --- /dev/null +++ b/packages/http-client-js/test/scenarios/client/nested_client.md @@ -0,0 +1,106 @@ +# Should generate a client structure that has a root client with a nested client + +This specs nests namespace > namespace > interface + +## TypeSpec + +```tsp +@service({ + title: "Widget Service", +}) +namespace DemoService; + +model Widget { + @visibility("read", "update") + @path + id: string; + + weight: int32; + color: "red" | "blue"; +} + +@error +model Error { + code: int32; + message: string; +} + +@route("/widgets") +@tag("Widgets") +interface Widgets { + @get list(): Widget[] | Error; + @get read(@path id: string): Widget | Error; + @post create(...Widget): Widget | Error; + @patch update(...Widget): Widget | Error; + @delete delete(@path id: string): void | Error; + @route("{id}/analyze") @post analyze(@path id: string): string | Error; +} +``` + +## TypeScript + +### Client + +It generates a class called TestClient with a single operation + +```ts src/demoServiceClient.ts +import { + DemoServiceClientContext, + DemoServiceClientOptions, + createDemoServiceClientContext, +} from "./api/demoServiceClientContext.js"; +import { + ListOptions, + list, + ReadOptions, + read, + CreateOptions, + create, + UpdateOptions, + update, + DeleteOptions, + delete_, + AnalyzeOptions, + analyze, +} from "./api/widgetsClient/widgetsClientOperations.js"; +import { + WidgetsClientContext, + WidgetsClientOptions, + createWidgetsClientContext, +} from "./api/widgetsClient/widgetsClientContext.js"; + +export class DemoServiceClient { + #context: DemoServiceClientContext; + widgetsClient: WidgetsClient; + constructor(endpoint: string, options?: DemoServiceClientOptions) { + this.#context = createDemoServiceClientContext(endpoint, options); + this.widgetsClient = new WidgetsClient(endpoint, options); + } +} + +export class WidgetsClient { + #context: WidgetsClientContext; + + constructor(endpoint: string, options?: WidgetsClientOptions) { + this.#context = createWidgetsClientContext(endpoint, options); + } + async list(options?: ListOptions) { + return list(this.#context, options); + } + async read(id: string, options?: ReadOptions) { + return read(this.#context, id, options); + } + async create(weight: number, color: "red" | "blue", options?: CreateOptions) { + return create(this.#context, weight, color, options); + } + async update(id: string, weight: number, color: "red" | "blue", options?: UpdateOptions) { + return update(this.#context, id, weight, color, options); + } + async delete_(id: string, options?: DeleteOptions) { + return delete_(this.#context, id, options); + } + async analyze(id: string, options?: AnalyzeOptions) { + return analyze(this.#context, id, options); + } +} +``` diff --git a/packages/http-client-js/test/scenarios/client/wrapping_namespace.md b/packages/http-client-js/test/scenarios/client/wrapping_namespace.md new file mode 100644 index 00000000000..c546573e419 --- /dev/null +++ b/packages/http-client-js/test/scenarios/client/wrapping_namespace.md @@ -0,0 +1,43 @@ +# Handles a structure where there is a namespace with sub namespaces only + +This scenario has a structure where the root namespace has no operations but has 2 sub namespaces. + +In this scenario the emitter is expected to resolve the root namespace as a client + +## Spec + +A dotted namespace where only the last part has stuff + +```tsp +@service({ + title: "TestService", +}) +namespace Foo; + +@route("/bar") +namespace Bar { + @get op getBar(): string[]; +} + +@route("/baz") +namespace Baz { + @get op getBaz(): string[]; +} +``` + +## Expectations + +The root client should be FooClient and should have 2 sub clients as members for bor and baz + +```ts src/fooClient.ts class FooClient +export class FooClient { + #context: FooClientContext; + barClient: BarClient; + bazClient: BazClient; + constructor(endpoint: string, options?: FooClientOptions) { + this.#context = createFooClientContext(endpoint, options); + this.barClient = new BarClient(endpoint, options); + this.bazClient = new BazClient(endpoint, options); + } +} +``` diff --git a/packages/http-client-js/test/scenarios/encoding/bytes_body.md b/packages/http-client-js/test/scenarios/encoding/bytes_body.md new file mode 100644 index 00000000000..965e7ccdec0 --- /dev/null +++ b/packages/http-client-js/test/scenarios/encoding/bytes_body.md @@ -0,0 +1,48 @@ +# Should not encode a bytes data when the body is bytes + +## TypeSpec + +```tsp +@service +namespace Test; + +@route("/default") +op foo(@header contentType: "application/octet-stream", @body value: bytes): { + @header contentType: "application/octet-stream"; + @body value: bytes; +}; +``` + +## TypeScript + +```ts src/api/testClientOperations.ts function foo +export async function foo( + client: TestClientContext, + value: Uint8Array, + options?: FooOptions, +): Promise { + const path = parse("/default").expand({}); + + const httpRequestOptions = { + headers: { + "content-type": options?.contentType ?? "application/octet-stream", + }, + body: value, + }; + + const response = await client.pathUnchecked(path).post(httpRequestOptions); + + if (typeof options?.operationOptions?.onResponse === "function") { + options?.operationOptions?.onResponse(response); + } + + if ( + +response.status === 200 && + response.headers["content-type"]?.includes("application/octet-stream") + ) { + return response.body!; + } + + throw createRestError(response); +} +``` diff --git a/packages/http-client-js/test/scenarios/encoding/bytes_json_property.md b/packages/http-client-js/test/scenarios/encoding/bytes_json_property.md new file mode 100644 index 00000000000..8a7254ed64b --- /dev/null +++ b/packages/http-client-js/test/scenarios/encoding/bytes_json_property.md @@ -0,0 +1,60 @@ +# Should not encode a bytes data when the body is bytes + +## TypeSpec + +```tsp +@service +namespace Test; + +model BytesBody { + value: bytes; +} + +@route("/default") +op foo(...BytesBody): BytesBody; +``` + +## Operation + +```ts src/api/testClientOperations.ts function foo +export async function foo( + client: TestClientContext, + value: Uint8Array, + options?: FooOptions, +): Promise { + const path = parse("/default").expand({}); + + const httpRequestOptions = { + headers: {}, + body: { + value: encodeUint8Array(value, "base64")!, + }, + }; + + const response = await client.pathUnchecked(path).post(httpRequestOptions); + + if (typeof options?.operationOptions?.onResponse === "function") { + options?.operationOptions?.onResponse(response); + } + + if (+response.status === 200 && response.headers["content-type"]?.includes("application/json")) { + return jsonBytesBodyToApplicationTransform(response.body)!; + } + + throw createRestError(response); +} +``` + +## Deserializer + +```ts src/models/serializers.ts function jsonBytesBodyToApplicationTransform +export function jsonBytesBodyToApplicationTransform(input_?: any): BytesBody { + if (!input_) { + return input_ as any; + } + + return { + value: decodeBase64(input_.value)!, + }!; +} +``` diff --git a/packages/http-client-js/test/scenarios/encoding/bytes_nullable.md b/packages/http-client-js/test/scenarios/encoding/bytes_nullable.md new file mode 100644 index 00000000000..aedd4eadc5c --- /dev/null +++ b/packages/http-client-js/test/scenarios/encoding/bytes_nullable.md @@ -0,0 +1,153 @@ +# Handle encoding with nullable bytes + +A spec that has an operation that uses a model with a nullable bytes property. + +```tsp +@service +namespace Test; +model ModelWithBytes { + requiredProperty: string; + nullableProperty: bytes | null; +} + +@get op get(): ModelWithBytes; +@put op put(...ModelWithBytes): void; +@post op post(body: ModelWithBytes): void; +``` + +## Get + +### Operation + +Should call the Json To Application transport for the ModelWith Bytes + +```ts src/api/testClientOperations.ts function get +export async function get( + client: TestClientContext, + options?: GetOptions, +): Promise { + const path = parse("/").expand({}); + + const httpRequestOptions = { + headers: {}, + }; + + const response = await client.pathUnchecked(path).get(httpRequestOptions); + + if (typeof options?.operationOptions?.onResponse === "function") { + options?.operationOptions?.onResponse(response); + } + + if (+response.status === 200 && response.headers["content-type"]?.includes("application/json")) { + return jsonModelWithBytesToApplicationTransform(response.body)!; + } + + throw createRestError(response); +} +``` + +### Deserializer + +Should decode as uint8array the nullableProperty + +```ts src/models/serializers.ts function jsonModelWithBytesToApplicationTransform +export function jsonModelWithBytesToApplicationTransform(input_?: any): ModelWithBytes { + if (!input_) { + return input_ as any; + } + + return { + requiredProperty: input_.requiredProperty, + nullableProperty: decodeBase64(input_.nullableProperty)!, + }!; +} +``` + +## Put + +### Operation + +Should call encode the nullable property when building the body as base64 + +```ts src/api/testClientOperations.ts function put +export async function put( + client: TestClientContext, + requiredProperty: string, + nullableProperty: Uint8Array | null, + options?: PutOptions, +): Promise { + const path = parse("/").expand({}); + + const httpRequestOptions = { + headers: {}, + body: { + requiredProperty: requiredProperty, + nullableProperty: encodeUint8Array(nullableProperty, "base64")!, + }, + }; + + const response = await client.pathUnchecked(path).put(httpRequestOptions); + + if (typeof options?.operationOptions?.onResponse === "function") { + options?.operationOptions?.onResponse(response); + } + + if (+response.status === 204 && !response.body) { + return; + } + + throw createRestError(response); +} +``` + +## Post + +### Operation + +Should call encode the JSON to Transport application transform function and pass the model + +```ts src/api/testClientOperations.ts function post +export async function post( + client: TestClientContext, + body: ModelWithBytes, + options?: PostOptions, +): Promise { + const path = parse("/").expand({}); + + const httpRequestOptions = { + headers: {}, + body: { + body: jsonModelWithBytesToTransportTransform(body), + }, + }; + + const response = await client.pathUnchecked(path).post(httpRequestOptions); + + if (typeof options?.operationOptions?.onResponse === "function") { + options?.operationOptions?.onResponse(response); + } + + if (+response.status === 204 && !response.body) { + return; + } + + throw createRestError(response); +} +``` + +### Serializer + +Should encode as base64 the nullableProperty + +```ts src/models/serializers.ts function jsonModelWithBytesToTransportTransform +export function jsonModelWithBytesToTransportTransform(input_?: ModelWithBytes | null): any { + if (!input_) { + return input_ as any; + } + + return { + requiredProperty: input_.requiredProperty, + nullableProperty: encodeUint8Array(input_.nullableProperty, "base64")!, + }!; +} +``` diff --git a/packages/http-client-js/test/scenarios/encoding/header_bytes.md b/packages/http-client-js/test/scenarios/encoding/header_bytes.md new file mode 100644 index 00000000000..ee50884b0ad --- /dev/null +++ b/packages/http-client-js/test/scenarios/encoding/header_bytes.md @@ -0,0 +1,142 @@ +# Should populate a bytes header parameter + +This scenario tests that a Bytes header parameter is sent correctly to the wire. Without any explicit encoding, the default is to encode as base64 + +## TypeSpec + +```tsp +@service +namespace Test; + +@route("/default") +op defaultEncoding( + @header + value: bytes, +): NoContentResponse; +``` + +## TypeScript + +```ts src/api/testClientOperations.ts function defaultEncoding +export async function defaultEncoding( + client: TestClientContext, + value: Uint8Array, + options?: DefaultEncodingOptions, +): Promise { + const path = parse("/default").expand({}); + + const httpRequestOptions = { + headers: { + value: encodeUint8Array(value, "base64")!, + }, + }; + + const response = await client.pathUnchecked(path).get(httpRequestOptions); + + if (typeof options?.operationOptions?.onResponse === "function") { + options?.operationOptions?.onResponse(response); + } + + if (+response.status === 204 && !response.body) { + return; + } + + throw createRestError(response); +} +``` + +# Should populate a bytes header parameter when the param is optional + +This scenario tests that a bytes header parameter which is optional, is sent correctly to the wire. Without any explicit encoding, the default is to encode as base64url + +## TypeSpec + +```tsp +@service +namespace Test; + +@route("/default") +op defaultEncoding( + @header + value?: bytes, +): NoContentResponse; +``` + +## TypeScript + +```ts src/api/testClientOperations.ts function defaultEncoding +export async function defaultEncoding( + client: TestClientContext, + options?: DefaultEncodingOptions, +): Promise { + const path = parse("/default").expand({}); + + const httpRequestOptions = { + headers: { + ...(options?.value && { + value: encodeUint8Array(options?.value, "base64")!, + }), + }, + }; + + const response = await client.pathUnchecked(path).get(httpRequestOptions); + + if (typeof options?.operationOptions?.onResponse === "function") { + options?.operationOptions?.onResponse(response); + } + + if (+response.status === 204 && !response.body) { + return; + } + + throw createRestError(response); +} +``` + +# Should populate a bytes header parameter with explicit encoding base64 + +This scenario tests that a bytes header parameter is sent correctly to the wire. Explicitly setting encoding to base65 + +## TypeSpec + +```tsp +@service +namespace Test; + +@route("/default") +op get( + @header + @encode("base64") + value: bytes, +): NoContentResponse; +``` + +## TypeScript + +```ts src/api/testClientOperations.ts function get +export async function get( + client: TestClientContext, + value: Uint8Array, + options?: GetOptions, +): Promise { + const path = parse("/default").expand({}); + + const httpRequestOptions = { + headers: { + value: encodeUint8Array(value, "base64")!, + }, + }; + + const response = await client.pathUnchecked(path).get(httpRequestOptions); + + if (typeof options?.operationOptions?.onResponse === "function") { + options?.operationOptions?.onResponse(response); + } + + if (+response.status === 204 && !response.body) { + return; + } + + throw createRestError(response); +} +``` diff --git a/packages/http-client-js/test/scenarios/encoding/header_date.md b/packages/http-client-js/test/scenarios/encoding/header_date.md new file mode 100644 index 00000000000..7781c2421da --- /dev/null +++ b/packages/http-client-js/test/scenarios/encoding/header_date.md @@ -0,0 +1,236 @@ +# Should populate a Date header parameter + +This scenario tests that a Date header parameter is sent correctly to the wire. Without any explicit encoding, the default is to encode as rfc3339 + +## TypeSpec + +```tsp +@service +namespace Test; + +@route("/default") +op defaultEncoding( + @header + value: utcDateTime, +): NoContentResponse; +``` + +## TypeScript + +```ts src/api/testClientOperations.ts function defaultEncoding +export async function defaultEncoding( + client: TestClientContext, + value: Date, + options?: DefaultEncodingOptions, +): Promise { + const path = parse("/default").expand({}); + + const httpRequestOptions = { + headers: { + value: dateRfc7231Serializer(value), + }, + }; + + const response = await client.pathUnchecked(path).get(httpRequestOptions); + + if (typeof options?.operationOptions?.onResponse === "function") { + options?.operationOptions?.onResponse(response); + } + + if (+response.status === 204 && !response.body) { + return; + } + + throw createRestError(response); +} +``` + +# Should populate a Date header parameter when the param is optional + +This scenario tests that a Date header parameter which is optional, is sent correctly to the wire. Without any explicit encoding, the default is to encode as rfc3339 + +## TypeSpec + +```tsp +@service +namespace Test; + +@route("/default") +op defaultEncoding( + @header + value?: utcDateTime, +): NoContentResponse; +``` + +## TypeScript + +```ts src/api/testClientOperations.ts function defaultEncoding +export async function defaultEncoding( + client: TestClientContext, + options?: DefaultEncodingOptions, +): Promise { + const path = parse("/default").expand({}); + + const httpRequestOptions = { + headers: { + ...(options?.value && { value: dateRfc7231Serializer(options?.value) }), + }, + }; + + const response = await client.pathUnchecked(path).get(httpRequestOptions); + + if (typeof options?.operationOptions?.onResponse === "function") { + options?.operationOptions?.onResponse(response); + } + + if (+response.status === 204 && !response.body) { + return; + } + + throw createRestError(response); +} +``` + +# Should populate a Date header parameter with explicit encoding rfc3339 + +This scenario tests that a Date header parameter is sent correctly to the wire. Explicitly setting encoding to rfc3339 + +## TypeSpec + +```tsp +@service +namespace Test; + +@route("/default") +op get( + @header + @encode(DateTimeKnownEncoding.rfc3339) + value: utcDateTime, +): NoContentResponse; +``` + +## TypeScript + +```ts src/api/testClientOperations.ts function get +export async function get( + client: TestClientContext, + value: Date, + options?: GetOptions, +): Promise { + const path = parse("/default").expand({}); + + const httpRequestOptions = { + headers: { + value: dateRfc3339Serializer(value), + }, + }; + + const response = await client.pathUnchecked(path).get(httpRequestOptions); + + if (typeof options?.operationOptions?.onResponse === "function") { + options?.operationOptions?.onResponse(response); + } + + if (+response.status === 204 && !response.body) { + return; + } + + throw createRestError(response); +} +``` + +# Should populate a Date header parameter with explicit encoding rfc7231 + +This scenario tests that a Date header parameter is sent correctly to the wire. Explicit encode set to rfc7231 + +## TypeSpec + +```tsp +@service +namespace Test; + +@route("/default") +op get( + @header + @encode(DateTimeKnownEncoding.rfc7231) + value: utcDateTime, +): NoContentResponse; +``` + +## TypeScript + +```ts src/api/testClientOperations.ts function get +export async function get( + client: TestClientContext, + value: Date, + options?: GetOptions, +): Promise { + const path = parse("/default").expand({}); + + const httpRequestOptions = { + headers: { + value: dateRfc7231Serializer(value), + }, + }; + + const response = await client.pathUnchecked(path).get(httpRequestOptions); + + if (typeof options?.operationOptions?.onResponse === "function") { + options?.operationOptions?.onResponse(response); + } + + if (+response.status === 204 && !response.body) { + return; + } + + throw createRestError(response); +} +``` + +# Should populate a Date header parameter with explicit encoding Unix Timestamp + +This scenario tests that a Date header parameter is sent correctly to the wire. Explicit encode set to unix timestamp + +## TypeSpec + +```tsp +@service +namespace Test; + +@route("/default") +op get( + @header + @encode("unixTimestamp", int64) + value: utcDateTime, +): NoContentResponse; +``` + +## TypeScript + +```ts src/api/testClientOperations.ts function get +export async function get( + client: TestClientContext, + value: Date, + options?: GetOptions, +): Promise { + const path = parse("/default").expand({}); + + const httpRequestOptions = { + headers: { + value: dateUnixTimestampSerializer(value), + }, + }; + + const response = await client.pathUnchecked(path).get(httpRequestOptions); + + if (typeof options?.operationOptions?.onResponse === "function") { + options?.operationOptions?.onResponse(response); + } + + if (+response.status === 204 && !response.body) { + return; + } + + throw createRestError(response); +} +``` diff --git a/packages/http-client-js/test/scenarios/encoding/model_with_bytes_property.md b/packages/http-client-js/test/scenarios/encoding/model_with_bytes_property.md new file mode 100644 index 00000000000..ae470f0ba6d --- /dev/null +++ b/packages/http-client-js/test/scenarios/encoding/model_with_bytes_property.md @@ -0,0 +1,50 @@ +# Should handle a model with bytes property + +```tsp +@route("/bytes") +namespace Test; +// Test a model with a bytes property +@doc("Model with a bytes property") +model BytesProperty { + property: bytes; +} + +@get op get(): BytesProperty; +@put op put(@body body: BytesProperty): void; +``` + +## TypeScript + +```ts src/api/testClientOperations.ts function get +export async function get(client: TestClientContext, options?: GetOptions): Promise { + const path = parse("/bytes").expand({}); + + const httpRequestOptions = { + headers: {}, + }; + + const response = await client.pathUnchecked(path).get(httpRequestOptions); + + if (typeof options?.operationOptions?.onResponse === "function") { + options?.operationOptions?.onResponse(response); + } + + if (+response.status === 200 && response.headers["content-type"]?.includes("application/json")) { + return jsonBytesPropertyToApplicationTransform(response.body)!; + } + + throw createRestError(response); +} +``` + +```ts src/models/serializers.ts function jsonBytesPropertyToApplicationTransform +export function jsonBytesPropertyToApplicationTransform(input_?: any): BytesProperty { + if (!input_) { + return input_ as any; + } + + return { + property: decodeBase64(input_.property)!, + }!; +} +``` diff --git a/packages/http-client-js/test/scenarios/encoding/query_bytes.md b/packages/http-client-js/test/scenarios/encoding/query_bytes.md new file mode 100644 index 00000000000..c4550881434 --- /dev/null +++ b/packages/http-client-js/test/scenarios/encoding/query_bytes.md @@ -0,0 +1,46 @@ +# Should populate a Date query parameter + +This scenario tests that a Bytes query parameter is sent correctly to the wire. Encoding as base64Url + +## TypeSpec + +```tsp +@service +namespace Test; + +@route("/default") +op defaultEncoding( + @query + value: bytes, +): NoContentResponse; +``` + +## TypeScript + +```ts src/api/testClientOperations.ts function defaultEncoding +export async function defaultEncoding( + client: TestClientContext, + value: Uint8Array, + options?: DefaultEncodingOptions, +): Promise { + const path = parse("/default{?value}").expand({ + value: encodeUint8Array(value, "base64url")!, + }); + + const httpRequestOptions = { + headers: {}, + }; + + const response = await client.pathUnchecked(path).get(httpRequestOptions); + + if (typeof options?.operationOptions?.onResponse === "function") { + options?.operationOptions?.onResponse(response); + } + + if (+response.status === 204 && !response.body) { + return; + } + + throw createRestError(response); +} +``` diff --git a/packages/http-client-js/test/scenarios/encoding/query_date.md b/packages/http-client-js/test/scenarios/encoding/query_date.md new file mode 100644 index 00000000000..2de67e4a9dd --- /dev/null +++ b/packages/http-client-js/test/scenarios/encoding/query_date.md @@ -0,0 +1,236 @@ +# Should populate a Date query parameter + +This scenario tests that a Date query parameter is sent correctly to the wire. Without any explicit encoding, the default is to encode as rfc3339 + +## TypeSpec + +```tsp +@service +namespace Test; + +@route("/default") +op defaultEncoding( + @query + value: utcDateTime, +): NoContentResponse; +``` + +## TypeScript + +```ts src/api/testClientOperations.ts function defaultEncoding +export async function defaultEncoding( + client: TestClientContext, + value: Date, + options?: DefaultEncodingOptions, +): Promise { + const path = parse("/default{?value}").expand({ + value: dateRfc3339Serializer(value), + }); + + const httpRequestOptions = { + headers: {}, + }; + + const response = await client.pathUnchecked(path).get(httpRequestOptions); + + if (typeof options?.operationOptions?.onResponse === "function") { + options?.operationOptions?.onResponse(response); + } + + if (+response.status === 204 && !response.body) { + return; + } + + throw createRestError(response); +} +``` + +# Should populate a Date query parameter when the param is optional + +This scenario tests that a Date query parameter which is optional, is sent correctly to the wire. Without any explicit encoding, the default is to encode as rfc3339 + +## TypeSpec + +```tsp +@service +namespace Test; + +@route("/default") +op defaultEncoding( + @query + value?: utcDateTime, +): NoContentResponse; +``` + +## TypeScript + +```ts src/api/testClientOperations.ts function defaultEncoding +export async function defaultEncoding( + client: TestClientContext, + options?: DefaultEncodingOptions, +): Promise { + const path = parse("/default{?value}").expand({ + ...(options?.value && { value: dateRfc3339Serializer(options?.value) }), + }); + + const httpRequestOptions = { + headers: {}, + }; + + const response = await client.pathUnchecked(path).get(httpRequestOptions); + + if (typeof options?.operationOptions?.onResponse === "function") { + options?.operationOptions?.onResponse(response); + } + + if (+response.status === 204 && !response.body) { + return; + } + + throw createRestError(response); +} +``` + +# Should populate a Date query parameter with explicit encoding rfc3339 + +This scenario tests that a Date query parameter is sent correctly to the wire. Explicitly setting encoding to rfc3339 + +## TypeSpec + +```tsp +@service +namespace Test; + +@route("/default") +op get( + @query + @encode(DateTimeKnownEncoding.rfc3339) + value: utcDateTime, +): NoContentResponse; +``` + +## TypeScript + +```ts src/api/testClientOperations.ts function get +export async function get( + client: TestClientContext, + value: Date, + options?: GetOptions, +): Promise { + const path = parse("/default{?value}").expand({ + value: dateRfc3339Serializer(value), + }); + + const httpRequestOptions = { + headers: {}, + }; + + const response = await client.pathUnchecked(path).get(httpRequestOptions); + + if (typeof options?.operationOptions?.onResponse === "function") { + options?.operationOptions?.onResponse(response); + } + + if (+response.status === 204 && !response.body) { + return; + } + + throw createRestError(response); +} +``` + +# onlyShould populate a Date query parameter with explicit encoding rfc7231 + +This scenario tests that a Date query parameter is sent correctly to the wire. Explicit encode set to rfc7231 + +## TypeSpec + +```tsp +@service +namespace Test; + +@route("/default") +op get( + @query + @encode(DateTimeKnownEncoding.rfc7231) + value: utcDateTime, +): NoContentResponse; +``` + +## TypeScript + +```ts src/api/testClientOperations.ts function get +export async function get( + client: TestClientContext, + value: Date, + options?: GetOptions, +): Promise { + const path = parse("/default{?value}").expand({ + value: dateRfc7231Serializer(value), + }); + + const httpRequestOptions = { + headers: {}, + }; + + const response = await client.pathUnchecked(path).get(httpRequestOptions); + + if (typeof options?.operationOptions?.onResponse === "function") { + options?.operationOptions?.onResponse(response); + } + + if (+response.status === 204 && !response.body) { + return; + } + + throw createRestError(response); +} +``` + +# Should populate a Date query parameter with explicit encoding Unix Timestamp + +This scenario tests that a Date query parameter is sent correctly to the wire. Explicit encode set to unix timestamp + +## TypeSpec + +```tsp +@service +namespace Test; + +@route("/default") +op get( + @query + @encode("unixTimestamp", int64) + value: utcDateTime, +): NoContentResponse; +``` + +## TypeScript + +```ts src/api/testClientOperations.ts function get +export async function get( + client: TestClientContext, + value: Date, + options?: GetOptions, +): Promise { + const path = parse("/default{?value}").expand({ + value: dateUnixTimestampSerializer(value), + }); + + const httpRequestOptions = { + headers: {}, + }; + + const response = await client.pathUnchecked(path).get(httpRequestOptions); + + if (typeof options?.operationOptions?.onResponse === "function") { + options?.operationOptions?.onResponse(response); + } + + if (+response.status === 204 && !response.body) { + return; + } + + throw createRestError(response); +} +``` diff --git a/packages/http-client-js/test/scenarios/http-operations/basic-request.md b/packages/http-client-js/test/scenarios/http-operations/basic-request.md new file mode 100644 index 00000000000..d6b411f4dfd --- /dev/null +++ b/packages/http-client-js/test/scenarios/http-operations/basic-request.md @@ -0,0 +1,162 @@ +# Should handle a basic request + +This test verifies that a basic `GET` request with no headers, body, or parameters is correctly handled. + +## TypeSpec + +```tsp +@service({ + title: "Widget Service", +}) +namespace DemoService; + +@route("/widgets") +@tag("Widgets") +interface Widgets { + @test @get read(): void; +} +``` + +## TypeScript + +### Request + +```ts src/api/widgetsClient/widgetsClientOperations.ts function read +export async function read(client: WidgetsClientContext, options?: ReadOptions): Promise { + const path = parse("/widgets").expand({}); + + const httpRequestOptions = { + headers: {}, + }; + + const response = await client.pathUnchecked(path).get(httpRequestOptions); + + if (typeof options?.operationOptions?.onResponse === "function") { + options?.operationOptions?.onResponse(response); + } + + if (+response.status === 204 && !response.body) { + return; + } + + throw createRestError(response); +} +``` + +# Should handle a request with headers, body, and query parameters + +This test verifies that a `POST` request with headers, a body, and query parameters is correctly handled. + +## TypeSpec + +```tsp +@service({ + title: "Widget Service", +}) +namespace DemoService; + +@test +model Widget { + @path id: string; + @header etag: string; + @query foo: string; + name: string; +} + +@route("/widgets") +@tag("Widgets") +interface Widgets { + @test @post read(...Widget): void; +} +``` + +## TypeScript + +### Request + +```ts src/api/widgetsClient/widgetsClientOperations.ts function read +export async function read( + client: WidgetsClientContext, + id: string, + etag: string, + foo: string, + name: string, + options?: ReadOptions, +): Promise { + const path = parse("/widgets/{id}{?foo}").expand({ + id: id, + foo: foo, + }); + + const httpRequestOptions = { + headers: { + etag: etag, + }, + body: { + name: name, + }, + }; + + const response = await client.pathUnchecked(path).post(httpRequestOptions); + + if (typeof options?.operationOptions?.onResponse === "function") { + options?.operationOptions?.onResponse(response); + } + + if (+response.status === 204 && !response.body) { + return; + } + + throw createRestError(response); +} +``` + +# Should handle a scalar body + +This test verifies that a `GET` request with a scalar body is correctly handled. + +## TypeSpec + +```tsp +@service({ + title: "Widget Service", +}) +namespace DemoService; + +@route("/widgets") +@tag("Widgets") +interface Widgets { + @test @get read(@body count: int32): void; +} +``` + +## TypeScript + +### Request + +```ts src/api/widgetsClient/widgetsClientOperations.ts function read +export async function read( + client: WidgetsClientContext, + count: number, + options?: ReadOptions, +): Promise { + const path = parse("/widgets").expand({}); + + const httpRequestOptions = { + headers: {}, + body: count, + }; + + const response = await client.pathUnchecked(path).get(httpRequestOptions); + + if (typeof options?.operationOptions?.onResponse === "function") { + options?.operationOptions?.onResponse(response); + } + + if (+response.status === 204 && !response.body) { + return; + } + + throw createRestError(response); +} +``` diff --git a/packages/http-client-js/test/scenarios/http-operations/basic-response.md b/packages/http-client-js/test/scenarios/http-operations/basic-response.md new file mode 100644 index 00000000000..58e43578537 --- /dev/null +++ b/packages/http-client-js/test/scenarios/http-operations/basic-response.md @@ -0,0 +1,246 @@ +# **Handling a Response with No Body (204 No Content)** + +This test verifies that a response with status `204` and no body is correctly handled. The generated TypeScript function should recognize an empty response and return `void` without errors. + +## **TypeSpec** + +```tsp +@service({ + title: "Widget Service", +}) +namespace DemoService; + +@route("/widgets") +@tag("Widgets") +interface Widgets { + @test @get read(): void; +} +``` + +## **TypeScript** + +### **Response Handling** + +```ts src/api/widgetsClient/widgetsClientOperations.ts function read +export async function read(client: WidgetsClientContext, options?: ReadOptions): Promise { + const path = parse("/widgets").expand({}); + + const httpRequestOptions = { + headers: {}, + }; + + const response = await client.pathUnchecked(path).get(httpRequestOptions); + + if (typeof options?.operationOptions?.onResponse === "function") { + options?.operationOptions?.onResponse(response); + } + + if (+response.status === 204 && !response.body) { + return; + } + + throw createRestError(response); +} +``` + +--- + +# **Handling a Response with a JSON Body** + +This test verifies that a response with a body containing a `Widget` model is correctly handled. The generated TypeScript function should deserialize the response body into a `Widget` instance. + +## **TypeSpec** + +```tsp +@service({ + title: "Widget Service", +}) +namespace DemoService; + +@test +model Widget { + name: string; + age: int32; +} + +@route("/widgets") +@tag("Widgets") +interface Widgets { + @test @get read(): Widget; +} +``` + +## **TypeScript** + +### **Deserializer** + +This function converts the received JSON response into a `Widget` instance. + +```ts src/models/serializers.ts function jsonWidgetToApplicationTransform +export function jsonWidgetToApplicationTransform(input_?: any): Widget { + if (!input_) { + return input_ as any; + } + + return { + name: input_.name, + age: input_.age, + }!; +} +``` + +### **Response Handling** + +The function reads a `Widget` instance from the response body, ensuring it only processes JSON responses with a `200` status. + +```ts src/api/widgetsClient/widgetsClientOperations.ts function read +export async function read(client: WidgetsClientContext, options?: ReadOptions): Promise { + const path = parse("/widgets").expand({}); + + const httpRequestOptions = { + headers: {}, + }; + + const response = await client.pathUnchecked(path).get(httpRequestOptions); + + if (typeof options?.operationOptions?.onResponse === "function") { + options?.operationOptions?.onResponse(response); + } + + if (+response.status === 200 && response.headers["content-type"]?.includes("application/json")) { + return jsonWidgetToApplicationTransform(response.body)!; + } + + throw createRestError(response); +} +``` + +--- + +# **Handling a Response with Multiple Status Codes (200 & 204)** + +This test verifies that a response with multiple status codes (`200` and `204`) is correctly handled. If the response is `200`, it should deserialize a `Widget`; if `204`, it should return `void`. + +## **TypeSpec** + +```tsp +@service({ + title: "Widget Service", +}) +namespace DemoService; + +@test +model Widget { + name: string; + age: int32; +} + +@route("/widgets") +@tag("Widgets") +interface Widgets { + @test @get read(): Widget | void; +} +``` + +## **TypeScript** + +### **Response Handling** + +The function determines the response type based on the status code. If `200`, it deserializes a `Widget`; if `204`, it returns `void`. + +```ts src/api/widgetsClient/widgetsClientOperations.ts function read +export async function read( + client: WidgetsClientContext, + options?: ReadOptions, +): Promise { + const path = parse("/widgets").expand({}); + + const httpRequestOptions = { + headers: {}, + }; + + const response = await client.pathUnchecked(path).get(httpRequestOptions); + + if (typeof options?.operationOptions?.onResponse === "function") { + options?.operationOptions?.onResponse(response); + } + + if (+response.status === 200 && response.headers["content-type"]?.includes("application/json")) { + return jsonWidgetToApplicationTransform(response.body)!; + } + + if (+response.status === 204 && !response.body) { + return; + } + + throw createRestError(response); +} +``` + +--- + +# **Handling a Response with Multiple Content Types** + +This test verifies that a response with multiple content types is correctly handled. The response can be in JSON or XML format, both containing a `Widget` model. + +## **TypeSpec** + +```tsp +@service({ + title: "Widget Service", +}) +namespace DemoService; + +model Widget { + name: string; + age: int32; +} + +model JsonResponse { + @body body: Widget; + @header contentType: "application/json"; +} + +model XmlResponse { + @body body: Widget; + @header contentType: "application/xml"; +} + +@route("/widgets") +interface Widgets { + @get read(): JsonResponse | XmlResponse; +} +``` + +## **TypeScript** + +### **Response Handling** + +This function ensures that the response is correctly processed based on its `content-type` header. It supports both JSON and XML responses, deserializing them into `Widget` instances. +TODO: need to implement xml serialization + +```ts src/api/widgetsClient/widgetsClientOperations.ts function read +export async function read(client: WidgetsClientContext, options?: ReadOptions): Promise { + const path = parse("/widgets").expand({}); + + const httpRequestOptions = { + headers: {}, + }; + + const response = await client.pathUnchecked(path).get(httpRequestOptions); + + if (typeof options?.operationOptions?.onResponse === "function") { + options?.operationOptions?.onResponse(response); + } + + if (+response.status === 200 && response.headers["content-type"]?.includes("application/json")) { + return jsonWidgetToApplicationTransform(response.body)!; + } + + if (+response.status === 200 && response.headers["content-type"]?.includes("application/xml")) { + return jsonWidgetToApplicationTransform(response.body)!; + } + + throw createRestError(response); +} +``` diff --git a/packages/http-client-js/test/scenarios/http-operations/basic.md b/packages/http-client-js/test/scenarios/http-operations/basic.md new file mode 100644 index 00000000000..39ed69bdb80 --- /dev/null +++ b/packages/http-client-js/test/scenarios/http-operations/basic.md @@ -0,0 +1,134 @@ +# **Generating a Basic HTTP Operation** + +This test verifies that a simple HTTP `GET` operation with no request payload or parameters correctly generates a **client class, model, serializer, context, and operation function**. The operation returns a `Widget` model, ensuring proper TypeScript type generation and serialization. + +## **TypeSpec** + +```tsp +@service +namespace Test; +model Widget { + id: string; + total_weight: int32; + color: "red" | "blue"; +} + +op foo(): Widget; +``` + +## **TypeScript** + +### **Client Generation** + +A class named `TestClient` is generated, encapsulating API operations. It includes a single method, `foo`, which internally calls the corresponding operation function. + +```ts src/testClient.ts +import { FooOptions, foo } from "./api/testClientOperations.js"; +import { + TestClientContext, + TestClientOptions, + createTestClientContext, +} from "./api/testClientContext.js"; + +export class TestClient { + #context: TestClientContext; + + constructor(endpoint: string, options?: TestClientOptions) { + this.#context = createTestClientContext(endpoint, options); + } + async foo(options?: FooOptions) { + return foo(this.#context, options); + } +} +``` + +### **Model Definition** + +A TypeScript interface for the `Widget` model is generated in `src/models/models.ts`. The field `total_weight` is renamed to `totalWeight` to align with TypeScript naming conventions. + +```ts src/models/models.ts interface Widget +export interface Widget { + id: string; + totalWeight: number; + color: "red" | "blue"; +} +``` + +### **Serializer Generation** + +A serializer function, `jsonWidgetToTransportTransform`, is generated to transform the `Widget` model into its transport format. It converts TypeScript-friendly property names (`totalWeight`) back to their wire format (`total_weight`). + +```ts src/models/serializers.ts function jsonWidgetToTransportTransform +export function jsonWidgetToTransportTransform(input_?: Widget | null): any { + if (!input_) { + return input_ as any; + } + + return { + id: input_.id, + total_weight: input_.totalWeight, + color: input_.color, + }!; +} +``` + +### **Context Generation** + +The generated `createTestClientContext` function initializes the API client context with the required endpoint. Since no authentication or additional parameters are used, only the endpoint is required. + +```ts src/api/testClientContext.ts function createTestClientContext +export function createTestClientContext( + endpoint: string, + options?: TestClientOptions, +): TestClientContext { + const params: Record = { + endpoint: endpoint, + }; + const resolvedEndpoint = "{endpoint}".replace(/{([^}]+)}/g, (_, key) => + key in params + ? String(params[key]) + : (() => { + throw new Error(`Missing parameter: ${key}`); + })(), + ); + return getClient(resolvedEndpoint, { + ...options, + }); +} +``` + +An interface, `TestClientContext`, is also generated to define the shape of the context. + +```ts src/api/testClientContext.ts interface TestClientContext +export interface TestClientContext extends Client {} +``` + +### **Operation Function** + +A function named `foo` is generated to handle the HTTP request. It prepares the request, sends it using the client context, and processes the response. + +- The request has **no query, path, or header parameters**. +- The expected response body is a `Widget`, requiring transformation from wire format using `jsonWidgetToApplication`. +- If the response status code is unexpected, an exception is thrown. + +```ts src/api/testClientOperations.ts function foo +export async function foo(client: TestClientContext, options?: FooOptions): Promise { + const path = parse("/").expand({}); + + const httpRequestOptions = { + headers: {}, + }; + + const response = await client.pathUnchecked(path).get(httpRequestOptions); + + if (typeof options?.operationOptions?.onResponse === "function") { + options?.operationOptions?.onResponse(response); + } + + if (+response.status === 200 && response.headers["content-type"]?.includes("application/json")) { + return jsonWidgetToApplicationTransform(response.body)!; + } + + throw createRestError(response); +} +``` diff --git a/packages/http-client-js/test/scenarios/http-operations/constant-header.md b/packages/http-client-js/test/scenarios/http-operations/constant-header.md new file mode 100644 index 00000000000..af3dafd3eaa --- /dev/null +++ b/packages/http-client-js/test/scenarios/http-operations/constant-header.md @@ -0,0 +1,37 @@ +# Should handle when provided a constant header + +```tsp +@service +namespace Test; +model Foo { + name: string; +} + +@get op foo(@header accept: "application/xml"): Foo; +``` + +## Operation + +```ts src/api/testClientOperations.ts function foo +export async function foo(client: TestClientContext, options?: FooOptions): Promise { + const path = parse("/").expand({}); + + const httpRequestOptions = { + headers: { + accept: options?.accept ?? "application/xml", + }, + }; + + const response = await client.pathUnchecked(path).get(httpRequestOptions); + + if (typeof options?.operationOptions?.onResponse === "function") { + options?.operationOptions?.onResponse(response); + } + + if (+response.status === 200 && response.headers["content-type"]?.includes("application/json")) { + return jsonFooToApplicationTransform(response.body)!; + } + + throw createRestError(response); +} +``` diff --git a/packages/http-client-js/test/scenarios/http-operations/create-operation.md b/packages/http-client-js/test/scenarios/http-operations/create-operation.md new file mode 100644 index 00000000000..22d863f56ed --- /dev/null +++ b/packages/http-client-js/test/scenarios/http-operations/create-operation.md @@ -0,0 +1,62 @@ +# Should generate a basic create http operation + +This is a simple get operation with no request payload or parameters and a simple model return. + +## TypeSpec + +```tsp +@service +namespace Test; +model Widget { + id: string; + total_weight: int32; + color: "red" | "blue"; + is_required?: boolean; +} + +@post op foo(...Widget): void; +``` + +## TypeScript + +### Operation + +Generates the operation function which prepares the request options. In this case it has not query, path or header parameters. No body either so headers is empty. + +The response body is of type Widget so the right transform should be imported to transform the widget from its wire format to the application form. + +It should throw an exception if an unexpected status code is received + +```ts src/api/testClientOperations.ts function foo +export async function foo( + client: TestClientContext, + id: string, + totalWeight: number, + color: "red" | "blue", + options?: FooOptions, +): Promise { + const path = parse("/").expand({}); + + const httpRequestOptions = { + headers: {}, + body: { + id: id, + total_weight: totalWeight, + color: color, + is_required: options?.isRequired, + }, + }; + + const response = await client.pathUnchecked(path).post(httpRequestOptions); + + if (typeof options?.operationOptions?.onResponse === "function") { + options?.operationOptions?.onResponse(response); + } + + if (+response.status === 204 && !response.body) { + return; + } + + throw createRestError(response); +} +``` diff --git a/packages/http-client-js/test/scenarios/http-operations/on_response.md b/packages/http-client-js/test/scenarios/http-operations/on_response.md new file mode 100644 index 00000000000..87911324249 --- /dev/null +++ b/packages/http-client-js/test/scenarios/http-operations/on_response.md @@ -0,0 +1,56 @@ +# Should call on response after receiving the response from the service. + +## TypeSpec + +```tsp +@service +namespace Test; +model Widget { + id: string; + total_weight: int32; + color: "red" | "blue"; + is_required?: boolean; +} + +@post op foo(...Widget): void; +``` + +## TypeScript + +### Operation + +Generates a function that call the onResponse callback + +```ts src/api/testClientOperations.ts function foo +export async function foo( + client: TestClientContext, + id: string, + totalWeight: number, + color: "red" | "blue", + options?: FooOptions, +): Promise { + const path = parse("/").expand({}); + + const httpRequestOptions = { + headers: {}, + body: { + id: id, + total_weight: totalWeight, + color: color, + is_required: options?.isRequired, + }, + }; + + const response = await client.pathUnchecked(path).post(httpRequestOptions); + + if (typeof options?.operationOptions?.onResponse === "function") { + options?.operationOptions?.onResponse(response); + } + + if (+response.status === 204 && !response.body) { + return; + } + + throw createRestError(response); +} +``` diff --git a/packages/http-client-js/test/scenarios/http-operations/optional-request-body.md b/packages/http-client-js/test/scenarios/http-operations/optional-request-body.md new file mode 100644 index 00000000000..58d15b47c89 --- /dev/null +++ b/packages/http-client-js/test/scenarios/http-operations/optional-request-body.md @@ -0,0 +1,55 @@ +# Should handle a request with an optional body + +```tsp +namespace Test; +model BodyModel { + name: string; +} + +@route("/set") +@post +op set(@body body?: BodyModel): NoContentResponse; + +@route("/omit") +@post +op omit(@body body?: BodyModel): NoContentResponse; +``` + +## Operations + +```ts src/api/testClientOperations.ts function set +export async function set(client: TestClientContext, options?: SetOptions): Promise { + const path = parse("/set").expand({}); + + const httpRequestOptions = { + headers: {}, + body: jsonBodyModelToTransportTransform(options?.body), + }; + + const response = await client.pathUnchecked(path).post(httpRequestOptions); + + if (typeof options?.operationOptions?.onResponse === "function") { + options?.operationOptions?.onResponse(response); + } + + if (+response.status === 204 && !response.body) { + return; + } + + throw createRestError(response); +} +``` + +## Transform + +```ts src/models/serializers.ts function jsonBodyModelToTransportTransform +export function jsonBodyModelToTransportTransform(input_?: BodyModel | null): any { + if (!input_) { + return input_ as any; + } + + return { + name: input_.name, + }!; +} +``` diff --git a/packages/http-client-js/test/scenarios/http-operations/path-parameter-in-model.md b/packages/http-client-js/test/scenarios/http-operations/path-parameter-in-model.md new file mode 100644 index 00000000000..19834c66a3c --- /dev/null +++ b/packages/http-client-js/test/scenarios/http-operations/path-parameter-in-model.md @@ -0,0 +1,59 @@ +# Request with that has a path parameter directly on the operation + +A request that sends a request with a path parameter as a property of the input model + +## TypeSpec + +The path parameter is a property of a spread model input in the operation signature + +```tsp +@service({ + title: "Widget Service", +}) +namespace DemoService; + +model ReadParams { + @path + id: string; +} + +@route("/widgets") +@tag("Widgets") +interface Widgets { + @test @get read(...ReadParams): void; +} +``` + +## TyeScript + +### Operations + +It should generate an operation that places the path parameter in the url template. + +```ts src/api/widgetsClient/widgetsClientOperations.ts function read +export async function read( + client: WidgetsClientContext, + id: string, + options?: ReadOptions, +): Promise { + const path = parse("/widgets/{id}").expand({ + id: id, + }); + + const httpRequestOptions = { + headers: {}, + }; + + const response = await client.pathUnchecked(path).get(httpRequestOptions); + + if (typeof options?.operationOptions?.onResponse === "function") { + options?.operationOptions?.onResponse(response); + } + + if (+response.status === 204 && !response.body) { + return; + } + + throw createRestError(response); +} +``` diff --git a/packages/http-client-js/test/scenarios/http-operations/path-parameter.md b/packages/http-client-js/test/scenarios/http-operations/path-parameter.md new file mode 100644 index 00000000000..d9708180b9b --- /dev/null +++ b/packages/http-client-js/test/scenarios/http-operations/path-parameter.md @@ -0,0 +1,54 @@ +# Request with that has a path parameter directly on the operation + +A request that sends a request with a path parameter directly on the operation signature + +## TypeSpec + +The path parameter is a positional parameter in the operation signature + +```tsp +@service({ + title: "Widget Service", +}) +namespace DemoService; + +@route("/widgets") +@tag("Widgets") +interface Widgets { + @test @get read(@path id: string): void; +} +``` + +## TyeScript + +### Operations + +It should generate an operation that places the path parameter in the url template. + +```ts src/api/widgetsClient/widgetsClientOperations.ts function read +export async function read( + client: WidgetsClientContext, + id: string, + options?: ReadOptions, +): Promise { + const path = parse("/widgets/{id}").expand({ + id: id, + }); + + const httpRequestOptions = { + headers: {}, + }; + + const response = await client.pathUnchecked(path).get(httpRequestOptions); + + if (typeof options?.operationOptions?.onResponse === "function") { + options?.operationOptions?.onResponse(response); + } + + if (+response.status === 204 && !response.body) { + return; + } + + throw createRestError(response); +} +``` diff --git a/packages/http-client-js/test/scenarios/http-operations/scalar-payload.md b/packages/http-client-js/test/scenarios/http-operations/scalar-payload.md new file mode 100644 index 00000000000..4b5e88ab317 --- /dev/null +++ b/packages/http-client-js/test/scenarios/http-operations/scalar-payload.md @@ -0,0 +1,63 @@ +# Request with a scalar body + +A request that sends a body payload of scalar type + +## TypeSpec + +The body is modeled as an explicit body property of type int32 + +```tsp +@service({ + title: "Widget Service", +}) +namespace DemoService; + +@route("/widgets") +@tag("Widgets") +interface Widgets { + @test @post create(@body count: int32): void; +} +``` + +## TyeScript + +### Operations + +It should generate an operation that sends the body as number. Since the body is an explicit property, a serializer function for the operation is created + +```ts src/api/widgetsClient/widgetsClientOperations.ts function create +export async function create( + client: WidgetsClientContext, + count: number, + options?: CreateOptions, +): Promise { + const path = parse("/widgets").expand({}); + + const httpRequestOptions = { + headers: {}, + body: count, + }; + + const response = await client.pathUnchecked(path).post(httpRequestOptions); + + if (typeof options?.operationOptions?.onResponse === "function") { + options?.operationOptions?.onResponse(response); + } + + if (+response.status === 204 && !response.body) { + return; + } + + throw createRestError(response); +} +``` + +### Serializers + +The correct serializer function is created, since this is a number payload, no additional serialization is needed. + +```ts src/models/serializers.ts function createPayloadToTransport +export function createPayloadToTransport(payload: number) { + return payload!; +} +``` diff --git a/packages/http-client-js/test/scenarios/http-operations/with-parameters.md b/packages/http-client-js/test/scenarios/http-operations/with-parameters.md new file mode 100644 index 00000000000..e58f852b86d --- /dev/null +++ b/packages/http-client-js/test/scenarios/http-operations/with-parameters.md @@ -0,0 +1,85 @@ +# Request with headers, body and query parameters + +A typical request with path, query header and body parameters. The body is modeled as a spread model to the operation + +## TypeSpec + +```tsp +@service({ + title: "Widget Service", +}) +namespace DemoService; + +@test +model Widget { + @path id: string; + @header etag: string; + @query foo: string; + name: string; +} + +@route("/widgets") +interface Widgets { + @post read(...Widget): void; +} +``` + +## TyeScript + +### Operations + +It should generate an operation placing the parameters in the right place. The path and query parameters as part of the url template, the headers in the request options and correctly place the body payload into the body within the http request options + +```ts src/api/widgetsClient/widgetsClientOperations.ts function read +export async function read( + client: WidgetsClientContext, + id: string, + etag: string, + foo: string, + name: string, + options?: ReadOptions, +): Promise { + const path = parse("/widgets/{id}{?foo}").expand({ + id: id, + foo: foo, + }); + + const httpRequestOptions = { + headers: { + etag: etag, + }, + body: { + name: name, + }, + }; + + const response = await client.pathUnchecked(path).post(httpRequestOptions); + + if (typeof options?.operationOptions?.onResponse === "function") { + options?.operationOptions?.onResponse(response); + } + + if (+response.status === 204 && !response.body) { + return; + } + + throw createRestError(response); +} +``` + +### Client Class + +It should generate the client class with the read operation that calls the operation under api + +```ts src/demoServiceClient.ts class WidgetsClient +export class WidgetsClient { + #context: WidgetsClientContext; + + constructor(endpoint: string, options?: WidgetsClientOptions) { + this.#context = createWidgetsClientContext(endpoint, options); + } + async read(id: string, etag: string, foo: string, name: string, options?: ReadOptions) { + return read(this.#context, id, etag, foo, name, options); + } +} +``` diff --git a/packages/http-client-js/test/scenarios/models/array-properties.md b/packages/http-client-js/test/scenarios/models/array-properties.md new file mode 100644 index 00000000000..08373c80ff5 --- /dev/null +++ b/packages/http-client-js/test/scenarios/models/array-properties.md @@ -0,0 +1,47 @@ +# Should generate a model with a property with array + +## TypeSpec + +```tsp +namespace Test; +model Widget { + id: string[]; + weight: int32[]; + color: ("red" | "blue")[]; +} +op foo(): Widget; +``` + +## TypeScript + +Should generate a model with name `Widget` that contains array properties + +```ts src/models/models.ts interface Widget +export interface Widget { + id: Array; + weight: Array; + color: Array<"red" | "blue">; +} +``` + +# Should generate a model with a property with array of record + +## TypeSpec + +```tsp +namespace Test; +model Widget { + id: Record[]; +} +op foo(): Widget; +``` + +## TypeScript + +Should generate a model with name `Widget` that contains array properties of record type + +```ts src/models/models.ts interface Widget +export interface Widget { + id: Array>; +} +``` diff --git a/packages/http-client-js/test/scenarios/models/basic.md b/packages/http-client-js/test/scenarios/models/basic.md new file mode 100644 index 00000000000..d4daae9ec1a --- /dev/null +++ b/packages/http-client-js/test/scenarios/models/basic.md @@ -0,0 +1,118 @@ +# Should generate a model from the global namespace + +## TypeSpec + +```tsp +model Widget { + id: string; + weight: int32; + color: "red" | "blue"; +} +op foo(): Widget; +``` + +## TypeScript + +Should generate a model with name `Widget` + +```ts src/models/models.ts interface Widget +export interface Widget { + id: string; + weight: number; + color: "red" | "blue"; +} +``` + +# Should generate a model from a namespace + +```tsp +namespace Test { + model TestWidget { + id: string; + weight: int32; + color: "red" | "blue"; + } + op foo(): TestWidget; +} +``` + +## TypeScript + +```ts src/models/models.ts interface TestWidget +export interface TestWidget { + id: string; + weight: number; + color: "red" | "blue"; +} +``` + +# Should generate a model from a nested namespace + +```tsp +namespace Test { + namespace Foo { + model TestFooWidget { + id: string; + weight: int32; + color: "red" | "blue"; + } + } + op foo(): Foo.TestFooWidget; +} +``` + +## TypeScript + +```ts src/models/models.ts interface TestFooWidget +export interface TestFooWidget { + id: string; + weight: number; + color: "red" | "blue"; +} +``` + +# Should generate a models with the same name in different namespaces + +```tsp +namespace Test { + namespace Foo { + model Widget { + id: string; + kind: "2"; + weight: int32; + color: "red" | "blue"; + } + } + + model Widget { + id: string; + kind: "1"; + weight: int32; + color: "red" | "blue"; + } + + op foo(): Widget | Foo.Widget; +} +``` + +## TypeScript + +```ts src/models/models.ts interface Widget +export interface Widget { + id: string; + kind: "1"; + weight: number; + color: "red" | "blue"; +} +``` + +The framework automatically resolves the name conflict. + +```ts src/models/models.ts interface Widget_2 +export interface Widget_2 { + id: string; + kind: "2"; + weight: number; + color: "red" | "blue"; +} +``` diff --git a/packages/http-client-js/test/scenarios/models/dictionary-properties.md b/packages/http-client-js/test/scenarios/models/dictionary-properties.md new file mode 100644 index 00000000000..866ee827859 --- /dev/null +++ b/packages/http-client-js/test/scenarios/models/dictionary-properties.md @@ -0,0 +1,44 @@ +# Should generate a model with a property which is a dictionary + +## TypeSpec + +```tsp +namespace Test; + +model Widget { + prop: Record; +} +op foo(): Widget; +``` + +## TypeScript + +Should generate a model with name `Widget` that contains dictionary properties + +```ts src/models/models.ts interface Widget +export interface Widget { + prop: Record; +} +``` + +# Should generate a model with a property which is a dictionary of an array + +## TypeSpec + +```tsp +namespace Test; +model Widget { + prop: Record; +} +op foo(): Widget; +``` + +## TypeScript + +Should generate a model with name `Widget` that contains dictionary properties with array + +```ts src/models/models.ts interface Widget +export interface Widget { + prop: Record>; +} +``` diff --git a/packages/http-client-js/test/scenarios/models/inheritance_2_discriminators.md b/packages/http-client-js/test/scenarios/models/inheritance_2_discriminators.md new file mode 100644 index 00000000000..5cc4ebe552b --- /dev/null +++ b/packages/http-client-js/test/scenarios/models/inheritance_2_discriminators.md @@ -0,0 +1,370 @@ +# Should handle a polymorphic Model with 2 discriminators + +```tsp +@service +namespace Test; + +@discriminator("kind") +model Fish { + age: int32; +} + +@discriminator("sharktype") +model Shark extends Fish { + kind: "shark"; + sharktype: string; +} + +model Salmon extends Fish { + kind: "salmon"; + friends?: Fish[]; + hate?: Record; + partner?: Fish; +} + +model SawShark extends Shark { + sharktype: "saw"; +} + +model GoblinShark extends Shark { + sharktype: "goblin"; +} + +@get +op getModel(): Fish; +``` + +## Operation + +```ts src/api/testClientOperations.ts function getModel +export async function getModel( + client: TestClientContext, + options?: GetModelOptions, +): Promise { + const path = parse("/").expand({}); + + const httpRequestOptions = { + headers: {}, + }; + + const response = await client.pathUnchecked(path).get(httpRequestOptions); + + if (typeof options?.operationOptions?.onResponse === "function") { + options?.operationOptions?.onResponse(response); + } + + if (+response.status === 200 && response.headers["content-type"]?.includes("application/json")) { + return jsonFishToApplicationTransform(response.body)!; + } + + throw createRestError(response); +} +``` + +```ts src/models/serializers.ts +import { Fish, Shark, SawShark, GoblinShark, Salmon } from "./models.js"; + +export function decodeBase64(value: string): Uint8Array | undefined { + if (!value) { + return value as any; + } + // Normalize Base64URL to Base64 + const base64 = value + .replace(/-/g, "+") + .replace(/_/g, "/") + .padEnd(value.length + ((4 - (value.length % 4)) % 4), "="); + + return new Uint8Array(Buffer.from(base64, "base64")); +} +export function encodeUint8Array( + value: Uint8Array | undefined | null, + encoding: BufferEncoding, +): string | undefined { + if (!value) { + return value as any; + } + return Buffer.from(value).toString(encoding); +} +export function dateDeserializer(date?: string | null): Date { + if (!date) { + return date as any; + } + + return new Date(date); +} +export function dateRfc7231Deserializer(date?: string | null): Date { + if (!date) { + return date as any; + } + + return new Date(date); +} +export function dateRfc3339Serializer(date?: Date | null): string { + if (!date) { + return date as any; + } + + return date.toISOString(); +} +export function dateRfc7231Serializer(date?: Date | null): string { + if (!date) { + return date as any; + } + + return date.toUTCString(); +} +export function dateUnixTimestampSerializer(date?: Date | null): number { + if (!date) { + return date as any; + } + + return Math.floor(date.getTime() / 1000); +} +export function dateUnixTimestampDeserializer(date?: number | null): Date { + if (!date) { + return date as any; + } + + return new Date(date * 1000); +} + +export function jsonFishToTransportDiscriminator(input_?: Fish): any { + if (!input_) { + return input_ as any; + } + const discriminatorValue = input_.kind; + if (discriminatorValue === "shark") { + return jsonSharkToTransportTransform(input_ as any)!; + } + + if (discriminatorValue === "salmon") { + return jsonSalmonToTransportTransform(input_ as any)!; + } + console.warn(`Received unknown kind: ` + discriminatorValue); + return input_ as any; +} +export function jsonFishToTransportTransform(input_?: Fish | null): any { + if (!input_) { + return input_ as any; + } + + return { + ...jsonFishToTransportDiscriminator(input_), + age: input_.age, + kind: input_.kind, + }!; +} + +export function jsonFishToApplicationDiscriminator(input_?: any): Fish { + if (!input_) { + return input_ as any; + } + const discriminatorValue = input_.kind; + if (discriminatorValue === "shark") { + return jsonSharkToApplicationTransform(input_ as any)!; + } + + if (discriminatorValue === "salmon") { + return jsonSalmonToApplicationTransform(input_ as any)!; + } + console.warn(`Received unknown kind: ` + discriminatorValue); + return input_ as any; +} +export function jsonFishToApplicationTransform(input_?: any): Fish { + if (!input_) { + return input_ as any; + } + + return { + ...jsonFishToApplicationDiscriminator(input_), + age: input_.age, + kind: input_.kind, + }!; +} + +export function jsonSharkToTransportDiscriminator(input_?: Shark): any { + if (!input_) { + return input_ as any; + } + const discriminatorValue = input_.sharktype; + if (discriminatorValue === "saw") { + return jsonSawSharkToTransportTransform(input_ as any)!; + } + + if (discriminatorValue === "goblin") { + return jsonGoblinSharkToTransportTransform(input_ as any)!; + } + console.warn(`Received unknown kind: ` + discriminatorValue); + return input_ as any; +} +export function jsonSharkToTransportTransform(input_?: Shark | null): any { + if (!input_) { + return input_ as any; + } + + return { + ...jsonSharkToTransportDiscriminator(input_), + kind: input_.kind, + sharktype: input_.sharktype, + age: input_.age, + }!; +} + +export function jsonSharkToApplicationDiscriminator(input_?: any): Shark { + if (!input_) { + return input_ as any; + } + const discriminatorValue = input_.sharktype; + if (discriminatorValue === "saw") { + return jsonSawSharkToApplicationTransform(input_ as any)!; + } + + if (discriminatorValue === "goblin") { + return jsonGoblinSharkToApplicationTransform(input_ as any)!; + } + console.warn(`Received unknown kind: ` + discriminatorValue); + return input_ as any; +} +export function jsonSharkToApplicationTransform(input_?: any): Shark { + if (!input_) { + return input_ as any; + } + + return { + ...jsonSharkToApplicationDiscriminator(input_), + kind: input_.kind, + sharktype: input_.sharktype, + age: input_.age, + }!; +} + +export function jsonSawSharkToTransportTransform(input_?: SawShark | null): any { + if (!input_) { + return input_ as any; + } + + return { + sharktype: input_.sharktype, + kind: input_.kind, + age: input_.age, + }!; +} + +export function jsonSawSharkToApplicationTransform(input_?: any): SawShark { + if (!input_) { + return input_ as any; + } + + return { + sharktype: input_.sharktype, + kind: input_.kind, + age: input_.age, + }!; +} + +export function jsonGoblinSharkToTransportTransform(input_?: GoblinShark | null): any { + if (!input_) { + return input_ as any; + } + + return { + sharktype: input_.sharktype, + kind: input_.kind, + age: input_.age, + }!; +} + +export function jsonGoblinSharkToApplicationTransform(input_?: any): GoblinShark { + if (!input_) { + return input_ as any; + } + + return { + sharktype: input_.sharktype, + kind: input_.kind, + age: input_.age, + }!; +} + +export function jsonSalmonToTransportTransform(input_?: Salmon | null): any { + if (!input_) { + return input_ as any; + } + + return { + kind: input_.kind, + friends: jsonArrayFishToTransportTransform(input_.friends), + hate: jsonRecordFishToTransportTransform(input_.hate), + partner: jsonFishToTransportTransform(input_.partner), + age: input_.age, + }!; +} + +export function jsonSalmonToApplicationTransform(input_?: any): Salmon { + if (!input_) { + return input_ as any; + } + + return { + kind: input_.kind, + friends: jsonArrayFishToApplicationTransform(input_.friends), + hate: jsonRecordFishToApplicationTransform(input_.hate), + partner: jsonFishToApplicationTransform(input_.partner), + age: input_.age, + }!; +} +export function jsonArrayFishToTransportTransform(items_?: Array | null): any { + if (!items_) { + return items_ as any; + } + const _transformedArray = []; + + for (const item of items_ ?? []) { + const transformedItem = jsonFishToTransportTransform(item as any); + _transformedArray.push(transformedItem); + } + + return _transformedArray as any; +} +export function jsonArrayFishToApplicationTransform(items_?: any): Array { + if (!items_) { + return items_ as any; + } + const _transformedArray = []; + + for (const item of items_ ?? []) { + const transformedItem = jsonFishToApplicationTransform(item as any); + _transformedArray.push(transformedItem); + } + + return _transformedArray as any; +} +export function jsonRecordFishToTransportTransform(items_?: Record | null): any { + if (!items_) { + return items_ as any; + } + + const _transformedRecord: any = {}; + + for (const [key, value] of Object.entries(items_ ?? {})) { + const transformedItem = jsonFishToTransportTransform(value as any); + _transformedRecord[key] = transformedItem; + } + + return _transformedRecord; +} +export function jsonRecordFishToApplicationTransform(items_?: any): Record { + if (!items_) { + return items_ as any; + } + + const _transformedRecord: any = {}; + + for (const [key, value] of Object.entries(items_ ?? {})) { + const transformedItem = jsonFishToApplicationTransform(value as any); + _transformedRecord[key] = transformedItem; + } + + return _transformedRecord; +} +``` diff --git a/packages/http-client-js/test/scenarios/models/inheritance_discriminator.md b/packages/http-client-js/test/scenarios/models/inheritance_discriminator.md new file mode 100644 index 00000000000..62245f6682f --- /dev/null +++ b/packages/http-client-js/test/scenarios/models/inheritance_discriminator.md @@ -0,0 +1,92 @@ +# Should model correctly a discriminated type by inheritance + +```tsp +@service +namespace Test; +@doc("Define a base class in the legacy way. Discriminator property is not explicitly defined in the model.") +@discriminator("kind") +model Dinosaur { + size: int32; +} + +@doc("The second level legacy model in polymorphic single level inheritance.") +model TRex extends Dinosaur { + kind: "t-rex"; +} + +@get +op getLegacyModel(): Dinosaur; +``` + +## Models + +```ts src/models/models.ts interface Dinosaur +export interface Dinosaur { + size: number; + kind: string; +} +``` + +```ts src/models/models.ts interface TRex +export interface TRex extends Dinosaur { + kind: "t-rex"; +} +``` + +## Serializer + +```ts src/models/serializers.ts function jsonDinosaurToTransportTransform +export function jsonDinosaurToTransportTransform(input_?: Dinosaur | null): any { + if (!input_) { + return input_ as any; + } + + return { + ...jsonDinosaurToTransportDiscriminator(input_), + size: input_.size, + kind: input_.kind, + }!; +} +``` + +```ts src/models/serializers.ts function jsonTRexToTransportTransform +export function jsonTRexToTransportTransform(input_?: TRex | null): any { + if (!input_) { + return input_ as any; + } + + return { + kind: input_.kind, + size: input_.size, + }!; +} +``` + +## Deserializer + +```ts src/models/serializers.ts function jsonDinosaurToApplicationTransform +export function jsonDinosaurToApplicationTransform(input_?: any): Dinosaur { + if (!input_) { + return input_ as any; + } + + return { + ...jsonDinosaurToApplicationDiscriminator(input_), + size: input_.size, + kind: input_.kind, + }!; +} +``` + +```ts src/models/serializers.ts function jsonTRexToApplicationTransform +export function jsonTRexToApplicationTransform(input_?: any): TRex { + if (!input_) { + return input_ as any; + } + + return { + kind: input_.kind, + size: input_.size, + }!; +} +``` diff --git a/packages/http-client-js/test/scenarios/models/inline-models.md b/packages/http-client-js/test/scenarios/models/inline-models.md new file mode 100644 index 00000000000..75bcf5138cc --- /dev/null +++ b/packages/http-client-js/test/scenarios/models/inline-models.md @@ -0,0 +1,29 @@ +# Should generate inline models + +When a model property's type is defined as an anonymous model, the TypeScript emitter should generate the model definition inline, matching the spec closely. + +## Typespec + +```tsp +namespace Test; +model Widget { + name: string; + subWidget: { + location: string; + age?: int32; + }; +} +op foo(): Widget; +``` + +## Typescript + +```ts src/models/models.ts interface Widget +export interface Widget { + name: string; + subWidget: { + location: string; + age?: number; + }; +} +``` diff --git a/packages/http-client-js/test/scenarios/models/model_additional_properties.md b/packages/http-client-js/test/scenarios/models/model_additional_properties.md new file mode 100644 index 00000000000..cb1870d0537 --- /dev/null +++ b/packages/http-client-js/test/scenarios/models/model_additional_properties.md @@ -0,0 +1,79 @@ +# Should generate a model that spreads a Record + +## TypeSpec + +```tsp +@service +namespace Test; +model ExtraFeature { + id: string; + name: string; + value: int32; +} + +model Dog { + id: string; + name: string; + color: "black" | "brown"; + ...Record; +} + +op foo(): Dog; +``` + +## Models + +```ts src/models/models.ts interface ExtraFeature +export interface ExtraFeature { + id: string; + name: string; + value: number; +} +``` + +```ts src/models/models.ts interface Dog +export interface Dog { + id: string; + name: string; + color: "black" | "brown"; + additionalProperties?: Record; +} +``` + +## Serializer + +```ts src/models/serializers.ts function jsonDogToTransportTransform +export function jsonDogToTransportTransform(input_?: Dog | null): any { + if (!input_) { + return input_ as any; + } + + return { + ...jsonRecordExtraFeatureToTransportTransform(input_.additionalProperties), + + id: input_.id, + name: input_.name, + color: input_.color, + }!; +} +``` + +## Deserializer + +```ts src/models/serializers.ts function jsonDogToApplicationTransform +export function jsonDogToApplicationTransform(input_?: any): Dog { + if (!input_) { + return input_ as any; + } + + return { + additionalProperties: jsonRecordExtraFeatureToApplicationTransform( + (({ id, name, color, ...rest }) => rest)(input_), + ), + + id: input_.id, + name: input_.name, + color: input_.color, + }!; +} +``` diff --git a/packages/http-client-js/test/scenarios/models/model_extends.md b/packages/http-client-js/test/scenarios/models/model_extends.md new file mode 100644 index 00000000000..6131b220772 --- /dev/null +++ b/packages/http-client-js/test/scenarios/models/model_extends.md @@ -0,0 +1,101 @@ +# Should generate a model that extends another model + +## TypeSpec + +```tsp +@service +namespace Test; +model Pet { + id: string; + name: string; +} + +model Dog extends Pet { + color: "black" | "brown"; +} + +op foo(): Dog; +``` + +## Models + +Should generate an interface for the base model and an interface for Dog that extends Pet + +```ts src/models/models.ts interface Pet +export interface Pet { + id: string; + name: string; +} +``` + +```ts src/models/models.ts interface Dog +export interface Dog extends Pet { + color: "black" | "brown"; +} +``` + +## Serializers + +Should generate a serializer for Pet. Also a serializer for Dog that calls the Pet serializer + +### Pet Serializer + +```ts src/models/serializers.ts function jsonPetToTransportTransform +export function jsonPetToTransportTransform(input_?: Pet | null): any { + if (!input_) { + return input_ as any; + } + + return { + id: input_.id, + name: input_.name, + }!; +} +``` + +### Pet deserializer + +```ts src/models/serializers.ts function jsonPetToApplicationTransform +export function jsonPetToApplicationTransform(input_?: any): Pet { + if (!input_) { + return input_ as any; + } + + return { + id: input_.id, + name: input_.name, + }!; +} +``` + +### Dog Serializer + +```ts src/models/serializers.ts function jsonDogToTransportTransform +export function jsonDogToTransportTransform(input_?: Dog | null): any { + if (!input_) { + return input_ as any; + } + + return { + color: input_.color, + id: input_.id, + name: input_.name, + }!; +} +``` + +### Dog deserializer + +```ts src/models/serializers.ts function jsonDogToApplicationTransform +export function jsonDogToApplicationTransform(input_?: any): Dog { + if (!input_) { + return input_ as any; + } + + return { + color: input_.color, + id: input_.id, + name: input_.name, + }!; +} +``` diff --git a/packages/http-client-js/test/scenarios/models/model_spread.md b/packages/http-client-js/test/scenarios/models/model_spread.md new file mode 100644 index 00000000000..c89e4fd47b0 --- /dev/null +++ b/packages/http-client-js/test/scenarios/models/model_spread.md @@ -0,0 +1,65 @@ +# Should generate a model that spreads another model + +## TypeSpec + +```tsp +@service +namespace Test; +model Pet { + id: string; + name: string; +} + +model Dog { + ...Pet; + color: "black" | "brown"; +} + +op foo(): Dog; +``` + +## Models + +Should generate an interface for the Dog that contains all properties from Pet + +```ts src/models/models.ts interface Dog +export interface Dog { + id: string; + name: string; + color: "black" | "brown"; +} +``` + +## Serializers + +### Dog Serializer + +```ts src/models/serializers.ts function jsonDogToTransportTransform +export function jsonDogToTransportTransform(input_?: Dog | null): any { + if (!input_) { + return input_ as any; + } + + return { + id: input_.id, + name: input_.name, + color: input_.color, + }!; +} +``` + +### Dog deserializer + +```ts src/models/serializers.ts function jsonDogToApplicationTransform +export function jsonDogToApplicationTransform(input_?: any): Dog { + if (!input_) { + return input_ as any; + } + + return { + id: input_.id, + name: input_.name, + color: input_.color, + }!; +} +``` diff --git a/packages/http-client-js/test/scenarios/models/nested_property.md b/packages/http-client-js/test/scenarios/models/nested_property.md new file mode 100644 index 00000000000..63512c05227 --- /dev/null +++ b/packages/http-client-js/test/scenarios/models/nested_property.md @@ -0,0 +1,45 @@ +# Should handle a model that has a property of type ModelProperty + +```tsp +@service +namespace Test; +model Request { + id: string; + profileImage: bytes; +} + +@post +@route("/foo") +op foo(profileImage: Request.profileImage): NoContentResponse; +``` + +## Operation + +```ts src/api/testClientOperations.ts function foo +export async function foo( + client: TestClientContext, + profileImage: Uint8Array, + options?: FooOptions, +): Promise { + const path = parse("/foo").expand({}); + + const httpRequestOptions = { + headers: {}, + body: { + profileImage: encodeUint8Array(profileImage, "base64")!, + }, + }; + + const response = await client.pathUnchecked(path).post(httpRequestOptions); + + if (typeof options?.operationOptions?.onResponse === "function") { + options?.operationOptions?.onResponse(response); + } + + if (+response.status === 204 && !response.body) { + return; + } + + throw createRestError(response); +} +``` diff --git a/packages/http-client-js/test/scenarios/models/property_references_property.md b/packages/http-client-js/test/scenarios/models/property_references_property.md new file mode 100644 index 00000000000..99b115fc900 --- /dev/null +++ b/packages/http-client-js/test/scenarios/models/property_references_property.md @@ -0,0 +1,156 @@ +# Should emit a model property which type is a reference to another Model Property + +This way of referencing a model property ends up with a ModelProperty which type is a ModelProperty. This test makes sure we can handle that. + +```tsp +namespace Test; + +model Foo { + name: string; + id: int32; +} + +model Bar { + address: string; + parentId: Foo.id; +} + +op get(...Bar): Bar; +``` + +## Model + +```ts src/models/models.ts interface Bar +export interface Bar { + address: string; + parentId: number; +} +``` + +## Transforms + +### To Transport + +Should emit a serializer for this model + +```ts src/models/serializers.ts function jsonBarToTransportTransform +export function jsonBarToTransportTransform(input_?: Bar | null): any { + if (!input_) { + return input_ as any; + } + + return { + address: input_.address, + parentId: input_.parentId, + }!; +} +``` + +Should emit an operation serializer + +```ts src/models/serializers.ts +import { Bar } from "./models.js"; + +export function decodeBase64(value: string): Uint8Array | undefined { + if (!value) { + return value as any; + } + // Normalize Base64URL to Base64 + const base64 = value + .replace(/-/g, "+") + .replace(/_/g, "/") + .padEnd(value.length + ((4 - (value.length % 4)) % 4), "="); + + return new Uint8Array(Buffer.from(base64, "base64")); +} +export function encodeUint8Array( + value: Uint8Array | undefined | null, + encoding: BufferEncoding, +): string | undefined { + if (!value) { + return value as any; + } + return Buffer.from(value).toString(encoding); +} +export function dateDeserializer(date?: string | null): Date { + if (!date) { + return date as any; + } + + return new Date(date); +} +export function dateRfc7231Deserializer(date?: string | null): Date { + if (!date) { + return date as any; + } + + return new Date(date); +} +export function dateRfc3339Serializer(date?: Date | null): string { + if (!date) { + return date as any; + } + + return date.toISOString(); +} +export function dateRfc7231Serializer(date?: Date | null): string { + if (!date) { + return date as any; + } + + return date.toUTCString(); +} +export function dateUnixTimestampSerializer(date?: Date | null): number { + if (!date) { + return date as any; + } + + return Math.floor(date.getTime() / 1000); +} +export function dateUnixTimestampDeserializer(date?: number | null): Date { + if (!date) { + return date as any; + } + + return new Date(date * 1000); +} + +export function jsonBarToTransportTransform(input_?: Bar | null): any { + if (!input_) { + return input_ as any; + } + + return { + address: input_.address, + parentId: input_.parentId, + }!; +} + +export function jsonBarToApplicationTransform(input_?: any): Bar { + if (!input_) { + return input_ as any; + } + + return { + address: input_.address, + parentId: input_.parentId, + }!; +} +``` + +### To Application + +Should emit a serializer for this model + +```ts src/models/serializers.ts function jsonBarToApplicationTransform +export function jsonBarToApplicationTransform(input_?: any): Bar { + if (!input_) { + return input_ as any; + } + + return { + address: input_.address, + parentId: input_.parentId, + }!; +} +``` diff --git a/packages/http-client-js/test/scenarios/multipart/anonymous_part.md b/packages/http-client-js/test/scenarios/multipart/anonymous_part.md new file mode 100644 index 00000000000..dc8b1aade32 --- /dev/null +++ b/packages/http-client-js/test/scenarios/multipart/anonymous_part.md @@ -0,0 +1,57 @@ +# Should handle an http part with anonymous model + +```tsp +@service +namespace Test; + +op foo( + @header contentType: "multipart/form-data", + @multipartBody body: { + temperature: HttpPart<{ + @body body: float64; + @header contentType: "text/plain"; + }>; + }, +): NoContentResponse; +``` + +## Operation + +```ts src/api/testClientOperations.ts function foo +export async function foo( + client: TestClientContext, + body: { + temperature: { + body: number; + contentType: "text/plain"; + }; + }, + options?: FooOptions, +): Promise { + const path = parse("/").expand({}); + + const httpRequestOptions = { + headers: { + "content-type": options?.contentType ?? "multipart/form-data", + }, + body: [ + { + name: "temperature", + body: body.temperature, + }, + ], + }; + + const response = await client.pathUnchecked(path).post(httpRequestOptions); + + if (typeof options?.operationOptions?.onResponse === "function") { + options?.operationOptions?.onResponse(response); + } + + if (+response.status === 204 && !response.body) { + return; + } + + throw createRestError(response); +} +``` diff --git a/packages/http-client-js/test/scenarios/multipart/file.md b/packages/http-client-js/test/scenarios/multipart/file.md new file mode 100644 index 00000000000..f4e3680611e --- /dev/null +++ b/packages/http-client-js/test/scenarios/multipart/file.md @@ -0,0 +1,166 @@ +# Basic file part + +```tsp +namespace Test; + +model RequestBody { + basicFile: HttpPart; +} + +op doThing(@header contentType: "multipart/form-data", @multipartBody bodyParam: RequestBody): void; +``` + +## Models + +This basic case uses TypeSpec's `Http.File`, which specifies an optional `filename` and `contentType`. + +```ts src/models/models.ts interface RequestBody +export interface RequestBody { + basicFile: File; +} +``` + +## Operations + +```ts src/api/testClientOperations.ts function doThing +export async function doThing( + client: TestClientContext, + bodyParam: RequestBody, + options?: DoThingOptions, +): Promise { + const path = parse("/").expand({}); + + const httpRequestOptions = { + headers: { + "content-type": options?.contentType ?? "multipart/form-data", + }, + body: [createFilePartDescriptor("basicFile", bodyParam.basicFile)], + }; + + const response = await client.pathUnchecked(path).post(httpRequestOptions); + + if (typeof options?.operationOptions?.onResponse === "function") { + options?.operationOptions?.onResponse(response); + } + + if (+response.status === 204 && !response.body) { + return; + } + + throw createRestError(response); +} +``` + +# With part content type + +```tsp +namespace Test; + +model PngFile extends File { + contentType: "image/png"; +} + +model RequestBody { + image: HttpPart; +} + +op doThing(@header contentType: "multipart/form-data", @multipartBody bodyParam: RequestBody): void; +``` + +## Models + +```ts src/models/models.ts interface PngFile +export interface PngFile extends File { + contentType: "image/png"; +} +``` + +```ts src/models/models.ts interface RequestBody +export interface RequestBody { + image: PngFile; +} +``` + +## Operation + +```ts src/api/testClientOperations.ts function doThing +export async function doThing( + client: TestClientContext, + bodyParam: RequestBody, + options?: DoThingOptions, +): Promise { + const path = parse("/").expand({}); + + const httpRequestOptions = { + headers: { + "content-type": options?.contentType ?? "multipart/form-data", + }, + body: [createFilePartDescriptor("image", bodyParam.image, "image/png")], + }; + + const response = await client.pathUnchecked(path).post(httpRequestOptions); + + if (typeof options?.operationOptions?.onResponse === "function") { + options?.operationOptions?.onResponse(response); + } + + if (+response.status === 204 && !response.body) { + return; + } + + throw createRestError(response); +} +``` + +# Multiple files + +```tsp +namespace Test; + +model RequestBody { + files: HttpPart[]; +} + +op doThing(@header contentType: "multipart/form-data", @multipartBody bodyParam: RequestBody): void; +``` + +## Models + +Each provided file in the input corresponds to one part in the multipart request. + +```ts src/models/models.ts interface RequestBody +export interface RequestBody { + files: Array; +} +``` + +## Operation + +```ts src/api/testClientOperations.ts function doThing +export async function doThing( + client: TestClientContext, + bodyParam: RequestBody, + options?: DoThingOptions, +): Promise { + const path = parse("/").expand({}); + + const httpRequestOptions = { + headers: { + "content-type": options?.contentType ?? "multipart/form-data", + }, + body: [...bodyParam.files.map((files: any) => createFilePartDescriptor("files", files))], + }; + + const response = await client.pathUnchecked(path).post(httpRequestOptions); + + if (typeof options?.operationOptions?.onResponse === "function") { + options?.operationOptions?.onResponse(response); + } + + if (+response.status === 204 && !response.body) { + return; + } + + throw createRestError(response); +} +``` diff --git a/packages/http-client-js/test/scenarios/multipart/file_content_type.md b/packages/http-client-js/test/scenarios/multipart/file_content_type.md new file mode 100644 index 00000000000..c6ad9ccf45f --- /dev/null +++ b/packages/http-client-js/test/scenarios/multipart/file_content_type.md @@ -0,0 +1,67 @@ +# Should handle part files with specific content type + +```tsp +namespace Test; +model FileSpecificContentType extends File { + filename: string; + contentType: "image/jpg"; +} + +model FileWithHttpPartSpecificContentTypeRequest { + profileImage: HttpPart; +} + +@post +@route("/check-filename-and-specific-content-type-with-httppart") +op imageJpegContentType( + @header contentType: "multipart/form-data", + @multipartBody body: FileWithHttpPartSpecificContentTypeRequest, +): NoContentResponse; +``` + +## Operations + +```ts src/api/testClientOperations.ts function imageJpegContentType +export async function imageJpegContentType( + client: TestClientContext, + body: FileWithHttpPartSpecificContentTypeRequest, + options?: ImageJpegContentTypeOptions, +): Promise { + const path = parse("/check-filename-and-specific-content-type-with-httppart").expand({}); + + const httpRequestOptions = { + headers: { + "content-type": options?.contentType ?? "multipart/form-data", + }, + body: [createFilePartDescriptor("profileImage", body.profileImage, "image/jpg")], + }; + + const response = await client.pathUnchecked(path).post(httpRequestOptions); + + if (typeof options?.operationOptions?.onResponse === "function") { + options?.operationOptions?.onResponse(response); + } + + if (+response.status === 204 && !response.body) { + return; + } + + throw createRestError(response); +} +``` + +## Serializers + +```ts src/models/serializers.ts function jsonFileWithHttpPartSpecificContentTypeRequestToApplicationTransform +export function jsonFileWithHttpPartSpecificContentTypeRequestToApplicationTransform( + input_?: any, +): FileWithHttpPartSpecificContentTypeRequest { + if (!input_) { + return input_ as any; + } + + return { + profileImage: jsonFileSpecificContentTypeToApplicationTransform(input_.profileImage), + }!; +} +``` diff --git a/packages/http-client-js/test/scenarios/multipart/simple_part.md b/packages/http-client-js/test/scenarios/multipart/simple_part.md new file mode 100644 index 00000000000..923aac44f37 --- /dev/null +++ b/packages/http-client-js/test/scenarios/multipart/simple_part.md @@ -0,0 +1,69 @@ +# Simple multipart part + +```tsp +namespace Test; + +model Foo { + name: HttpPart; + age: HttpPart; + description?: HttpPart; +} + +op doThing(@header contentType: "multipart/form-data", @multipartBody bodyParam: Foo): void; +``` + +## Models + +This basic case uses TypeSpec's `Http.File`, which specifies an optional `filename` and `contentType`. + +```ts src/models/models.ts interface Foo +export interface Foo { + name: string; + age: number; + description?: string; +} +``` + +## Operations + +```ts src/api/testClientOperations.ts function doThing +export async function doThing( + client: TestClientContext, + bodyParam: Foo, + options?: DoThingOptions, +): Promise { + const path = parse("/").expand({}); + + const httpRequestOptions = { + headers: { + "content-type": options?.contentType ?? "multipart/form-data", + }, + body: [ + { + name: "name", + body: bodyParam.name, + }, + { + name: "age", + body: bodyParam.age, + }, + { + name: "description", + body: bodyParam.description, + }, + ], + }; + + const response = await client.pathUnchecked(path).post(httpRequestOptions); + + if (typeof options?.operationOptions?.onResponse === "function") { + options?.operationOptions?.onResponse(response); + } + + if (+response.status === 204 && !response.body) { + return; + } + + throw createRestError(response); +} +``` diff --git a/packages/http-client-js/test/scenarios/operation-parameters/body_root_anonymous.md b/packages/http-client-js/test/scenarios/operation-parameters/body_root_anonymous.md new file mode 100644 index 00000000000..3be7ed9058d --- /dev/null +++ b/packages/http-client-js/test/scenarios/operation-parameters/body_root_anonymous.md @@ -0,0 +1,92 @@ +# Should emit an operation that has a @bodyRoot set. + +```tsp +@service +namespace Test; + +@post op create( + @bodyRoot widget: { + id: string; + name: string; + age?: string; + @header foo?: string; + }, +): void; +``` + +## Operation + +The operation has has no required parameters so options and client should be the only ones in the signature + +```ts src/api/testClientOperations.ts function create +export async function create( + client: TestClientContext, + widget: { + id: string; + name: string; + age?: string; + foo?: string; + }, + options?: CreateOptions, +): Promise { + const path = parse("/").expand({}); + + const httpRequestOptions = { + headers: { + ...(options?.foo && { foo: options?.foo }), + }, + body: { + id: widget.id, + name: widget.name, + age: widget.options?.age, + foo: widget.options?.foo, + }, + }; + + const response = await client.pathUnchecked(path).post(httpRequestOptions); + + if (typeof options?.operationOptions?.onResponse === "function") { + options?.operationOptions?.onResponse(response); + } + + if (+response.status === 204 && !response.body) { + return; + } + + throw createRestError(response); +} +``` + +## Options + +The options bag should like all the optional parameters of the operation + +```ts src/api/testClientOperations.ts interface CreateOptions +export interface CreateOptions extends OperationOptions { + age?: string; + foo?: string; +} +``` + +## Client + +```ts src/testClient.ts class TestClient +export class TestClient { + #context: TestClientContext; + + constructor(endpoint: string, options?: TestClientOptions) { + this.#context = createTestClientContext(endpoint, options); + } + async create( + widget: { + id: string; + name: string; + age?: string; + foo?: string; + }, + options?: CreateOptions, + ) { + return create(this.#context, widget, options); + } +} +``` diff --git a/packages/http-client-js/test/scenarios/operation-parameters/constant_as_optional.md b/packages/http-client-js/test/scenarios/operation-parameters/constant_as_optional.md new file mode 100644 index 00000000000..4813dbb31cb --- /dev/null +++ b/packages/http-client-js/test/scenarios/operation-parameters/constant_as_optional.md @@ -0,0 +1,59 @@ +# Should emit an operation that has a content type with a single possible value + +```tsp +@service +namespace Test; + +@get op get(@header contentType: "application/json"): int32; +``` + +## Operation + +Even when there are no parameters defined in the spec, it will have an optional options bag which contains operation options. + +```ts src/api/testClientOperations.ts function get +export async function get(client: TestClientContext, options?: GetOptions): Promise { + const path = parse("/").expand({}); + + const httpRequestOptions = { + headers: { + "content-type": options?.contentType ?? "application/json", + }, + }; + + const response = await client.pathUnchecked(path).get(httpRequestOptions); + + if (typeof options?.operationOptions?.onResponse === "function") { + options?.operationOptions?.onResponse(response); + } + + if (+response.status === 200 && response.headers["content-type"]?.includes("application/json")) { + return response.body!; + } + + throw createRestError(response); +} +``` + +## Options + +```ts src/api/testClientOperations.ts interface GetOptions +export interface GetOptions extends OperationOptions { + contentType?: "application/json"; +} +``` + +## Client + +```ts src/testClient.ts class TestClient +export class TestClient { + #context: TestClientContext; + + constructor(endpoint: string, options?: TestClientOptions) { + this.#context = createTestClientContext(endpoint, options); + } + async get(options?: GetOptions) { + return get(this.#context, options); + } +} +``` diff --git a/packages/http-client-js/test/scenarios/operation-parameters/default_value_as_optional.md b/packages/http-client-js/test/scenarios/operation-parameters/default_value_as_optional.md new file mode 100644 index 00000000000..205e30b7a41 --- /dev/null +++ b/packages/http-client-js/test/scenarios/operation-parameters/default_value_as_optional.md @@ -0,0 +1,59 @@ +# Should emit an operation that has a default value + +```tsp +@service +namespace Test; + +@get op get(@header contentType: string = "application/json"): int32; +``` + +## Operation + +Even when there are no parameters defined in the spec, it will have an optional options bag which contains operation options. + +```ts src/api/testClientOperations.ts function get +export async function get(client: TestClientContext, options?: GetOptions): Promise { + const path = parse("/").expand({}); + + const httpRequestOptions = { + headers: { + "content-type": options?.contentType ?? "application/json", + }, + }; + + const response = await client.pathUnchecked(path).get(httpRequestOptions); + + if (typeof options?.operationOptions?.onResponse === "function") { + options?.operationOptions?.onResponse(response); + } + + if (+response.status === 200 && response.headers["content-type"]?.includes("application/json")) { + return response.body!; + } + + throw createRestError(response); +} +``` + +## Options + +```ts src/api/testClientOperations.ts interface GetOptions +export interface GetOptions extends OperationOptions { + contentType?: string; +} +``` + +## Client + +```ts src/testClient.ts class TestClient +export class TestClient { + #context: TestClientContext; + + constructor(endpoint: string, options?: TestClientOptions) { + this.#context = createTestClientContext(endpoint, options); + } + async get(options?: GetOptions) { + return get(this.#context, options); + } +} +``` diff --git a/packages/http-client-js/test/scenarios/operation-parameters/no_content_type.md b/packages/http-client-js/test/scenarios/operation-parameters/no_content_type.md new file mode 100644 index 00000000000..40fb1cd6c76 --- /dev/null +++ b/packages/http-client-js/test/scenarios/operation-parameters/no_content_type.md @@ -0,0 +1,67 @@ +# Should emit an operation that has a no content type explicitly defined. + +```tsp +@service +namespace Test; +model Foo { + id: string; + name: string; +} +@post op get(...Foo): void; +``` + +## Operation + +Even when there are no parameters defined in the spec, it will have an optional options bag which contains operation options. + +```ts src/api/testClientOperations.ts function get +export async function get( + client: TestClientContext, + id: string, + name: string, + options?: GetOptions, +): Promise { + const path = parse("/").expand({}); + + const httpRequestOptions = { + headers: {}, + body: { + id: id, + name: name, + }, + }; + + const response = await client.pathUnchecked(path).post(httpRequestOptions); + + if (typeof options?.operationOptions?.onResponse === "function") { + options?.operationOptions?.onResponse(response); + } + + if (+response.status === 204 && !response.body) { + return; + } + + throw createRestError(response); +} +``` + +## Options + +```ts src/api/testClientOperations.ts interface GetOptions +export interface GetOptions extends OperationOptions {} +``` + +## Client + +```ts src/testClient.ts class TestClient +export class TestClient { + #context: TestClientContext; + + constructor(endpoint: string, options?: TestClientOptions) { + this.#context = createTestClientContext(endpoint, options); + } + async get(id: string, name: string, options?: GetOptions) { + return get(this.#context, id, name, options); + } +} +``` diff --git a/packages/http-client-js/test/scenarios/operation-parameters/no_parameters.md b/packages/http-client-js/test/scenarios/operation-parameters/no_parameters.md new file mode 100644 index 00000000000..226c7794b71 --- /dev/null +++ b/packages/http-client-js/test/scenarios/operation-parameters/no_parameters.md @@ -0,0 +1,55 @@ +# Should emit an operation that has no parameters + +```tsp +@service +namespace Test; + +@get op get(): int32; +``` + +## Operation + +Even when there are no parameters defined in the spec, it will have an optional options bag which contains operation options. + +```ts src/api/testClientOperations.ts function get +export async function get(client: TestClientContext, options?: GetOptions): Promise { + const path = parse("/").expand({}); + + const httpRequestOptions = { + headers: {}, + }; + + const response = await client.pathUnchecked(path).get(httpRequestOptions); + + if (typeof options?.operationOptions?.onResponse === "function") { + options?.operationOptions?.onResponse(response); + } + + if (+response.status === 200 && response.headers["content-type"]?.includes("application/json")) { + return response.body!; + } + + throw createRestError(response); +} +``` + +## Options + +```ts src/api/testClientOperations.ts interface GetOptions +export interface GetOptions extends OperationOptions {} +``` + +## Client + +```ts src/testClient.ts class TestClient +export class TestClient { + #context: TestClientContext; + + constructor(endpoint: string, options?: TestClientOptions) { + this.#context = createTestClientContext(endpoint, options); + } + async get(options?: GetOptions) { + return get(this.#context, options); + } +} +``` diff --git a/packages/http-client-js/test/scenarios/operation-parameters/only_optional.md b/packages/http-client-js/test/scenarios/operation-parameters/only_optional.md new file mode 100644 index 00000000000..9e4faa6ded2 --- /dev/null +++ b/packages/http-client-js/test/scenarios/operation-parameters/only_optional.md @@ -0,0 +1,67 @@ +# Should emit an operation that has only optional parameters + +```tsp +@service +namespace Test; + +@get op getWithParams(name?: string, age?: int32): int32; +``` + +## Operation + +The operation has has no required parameters so options and client should be the only ones in the signature + +```ts src/api/testClientOperations.ts function getWithParams +export async function getWithParams( + client: TestClientContext, + options?: GetWithParamsOptions, +): Promise { + const path = parse("/").expand({}); + + const httpRequestOptions = { + headers: {}, + body: { + name: options?.name, + age: options?.age, + }, + }; + + const response = await client.pathUnchecked(path).get(httpRequestOptions); + + if (typeof options?.operationOptions?.onResponse === "function") { + options?.operationOptions?.onResponse(response); + } + + if (+response.status === 200 && response.headers["content-type"]?.includes("application/json")) { + return response.body!; + } + + throw createRestError(response); +} +``` + +## Options + +The options bag should like all the optional parameters of the operation + +```ts src/api/testClientOperations.ts interface GetWithParamsOptions +export interface GetWithParamsOptions extends OperationOptions { + name?: string; + age?: number; +} +``` + +## Client + +```ts src/testClient.ts class TestClient +export class TestClient { + #context: TestClientContext; + + constructor(endpoint: string, options?: TestClientOptions) { + this.#context = createTestClientContext(endpoint, options); + } + async getWithParams(options?: GetWithParamsOptions) { + return getWithParams(this.#context, options); + } +} +``` diff --git a/packages/http-client-js/test/scenarios/operation-parameters/only_required.md b/packages/http-client-js/test/scenarios/operation-parameters/only_required.md new file mode 100644 index 00000000000..fd5af5fb6f9 --- /dev/null +++ b/packages/http-client-js/test/scenarios/operation-parameters/only_required.md @@ -0,0 +1,63 @@ +# Should emit an operation that has required parameters + +```tsp +@service +namespace Test; + +@get op getWithParams(@query name: string, @query age: int32): int32; +``` + +## Operation + +The operation has required parameters defined in the spec, which will be included in the options bag. + +```ts src/api/testClientOperations.ts function getWithParams +export async function getWithParams( + client: TestClientContext, + name: string, + age: number, + options?: GetWithParamsOptions, +): Promise { + const path = parse("/{?name,age}").expand({ + name: name, + age: age, + }); + + const httpRequestOptions = { + headers: {}, + }; + + const response = await client.pathUnchecked(path).get(httpRequestOptions); + + if (typeof options?.operationOptions?.onResponse === "function") { + options?.operationOptions?.onResponse(response); + } + + if (+response.status === 200 && response.headers["content-type"]?.includes("application/json")) { + return response.body!; + } + + throw createRestError(response); +} +``` + +## Options + +```ts src/api/testClientOperations.ts interface GetWithParamsOptions +export interface GetWithParamsOptions extends OperationOptions {} +``` + +## Client + +```ts src/testClient.ts class TestClient +export class TestClient { + #context: TestClientContext; + + constructor(endpoint: string, options?: TestClientOptions) { + this.#context = createTestClientContext(endpoint, options); + } + async getWithParams(name: string, age: number, options?: GetWithParamsOptions) { + return getWithParams(this.#context, name, age, options); + } +} +``` diff --git a/packages/http-client-js/test/scenarios/operation-parameters/reserved_names.md b/packages/http-client-js/test/scenarios/operation-parameters/reserved_names.md new file mode 100644 index 00000000000..05e892271cb --- /dev/null +++ b/packages/http-client-js/test/scenarios/operation-parameters/reserved_names.md @@ -0,0 +1,65 @@ +# Should emit an operation that has parameters with reserved words + +```tsp +@service +namespace Test; + +@get op get(await: string, break?: boolean): void; +``` + +## Operation + +Even when there are no parameters defined in the spec, it will have an optional options bag which contains operation options. + +```ts src/api/testClientOperations.ts function get +export async function get( + client: TestClientContext, + await_: string, + options?: GetOptions, +): Promise { + const path = parse("/").expand({}); + + const httpRequestOptions = { + headers: {}, + body: { + await: await_, + break: options?.break_, + }, + }; + + const response = await client.pathUnchecked(path).get(httpRequestOptions); + + if (typeof options?.operationOptions?.onResponse === "function") { + options?.operationOptions?.onResponse(response); + } + + if (+response.status === 204 && !response.body) { + return; + } + + throw createRestError(response); +} +``` + +## Options + +```ts src/api/testClientOperations.ts interface GetOptions +export interface GetOptions extends OperationOptions { + break_?: boolean; +} +``` + +## Client + +```ts src/testClient.ts class TestClient +export class TestClient { + #context: TestClientContext; + + constructor(endpoint: string, options?: TestClientOptions) { + this.#context = createTestClientContext(endpoint, options); + } + async get(await_: string, options?: GetOptions) { + return get(this.#context, await_, options); + } +} +``` diff --git a/packages/http-client-js/test/scenarios/operation-parameters/spread_body.md b/packages/http-client-js/test/scenarios/operation-parameters/spread_body.md new file mode 100644 index 00000000000..a843e9eec1c --- /dev/null +++ b/packages/http-client-js/test/scenarios/operation-parameters/spread_body.md @@ -0,0 +1,75 @@ +# Should emit an operation that has a spread model as parameters + +```tsp +@service +namespace Test; + +model Widget { + id: string; + name: string; + age?: string; +} + +@post op create(...Widget): void; +``` + +## Operation + +The operation has has no required parameters so options and client should be the only ones in the signature + +```ts src/api/testClientOperations.ts function create +export async function create( + client: TestClientContext, + id: string, + name: string, + options?: CreateOptions, +): Promise { + const path = parse("/").expand({}); + + const httpRequestOptions = { + headers: {}, + body: { + id: id, + name: name, + age: options?.age, + }, + }; + + const response = await client.pathUnchecked(path).post(httpRequestOptions); + + if (typeof options?.operationOptions?.onResponse === "function") { + options?.operationOptions?.onResponse(response); + } + + if (+response.status === 204 && !response.body) { + return; + } + + throw createRestError(response); +} +``` + +## Options + +The options bag should like all the optional parameters of the operation + +```ts src/api/testClientOperations.ts interface CreateOptions +export interface CreateOptions extends OperationOptions { + age?: string; +} +``` + +## Client + +```ts src/testClient.ts class TestClient +export class TestClient { + #context: TestClientContext; + + constructor(endpoint: string, options?: TestClientOptions) { + this.#context = createTestClientContext(endpoint, options); + } + async create(id: string, name: string, options?: CreateOptions) { + return create(this.#context, id, name, options); + } +} +``` diff --git a/packages/http-client-js/test/scenarios/operation-parameters/spread_with_nested.md b/packages/http-client-js/test/scenarios/operation-parameters/spread_with_nested.md new file mode 100644 index 00000000000..7f9c4f22a80 --- /dev/null +++ b/packages/http-client-js/test/scenarios/operation-parameters/spread_with_nested.md @@ -0,0 +1,87 @@ +# Should emit an operation that has a spread model with a nested model as parameter + +```tsp +@service +namespace Test; + +model Address { + street: string; + city: string; + state: string; + zipCode: string; + interiorNumber?: string; +} + +model Widget { + id: string; + name: string; + age?: string; + address?: Address; +} + +@post op create(...Widget): void; +``` + +## Operation + +The operation has has no required parameters so options and client should be the only ones in the signature + +```ts src/api/testClientOperations.ts function create +export async function create( + client: TestClientContext, + id: string, + name: string, + options?: CreateOptions, +): Promise { + const path = parse("/").expand({}); + + const httpRequestOptions = { + headers: {}, + body: { + id: id, + name: name, + age: options?.age, + address: jsonAddressToTransportTransform(options?.address), + }, + }; + + const response = await client.pathUnchecked(path).post(httpRequestOptions); + + if (typeof options?.operationOptions?.onResponse === "function") { + options?.operationOptions?.onResponse(response); + } + + if (+response.status === 204 && !response.body) { + return; + } + + throw createRestError(response); +} +``` + +## Options + +The options bag should like all the optional parameters of the operation + +```ts src/api/testClientOperations.ts interface CreateOptions +export interface CreateOptions extends OperationOptions { + age?: string; + interiorNumber?: string; + address?: Address; +} +``` + +## Client + +```ts src/testClient.ts class TestClient +export class TestClient { + #context: TestClientContext; + + constructor(endpoint: string, options?: TestClientOptions) { + this.#context = createTestClientContext(endpoint, options); + } + async create(id: string, name: string, options?: CreateOptions) { + return create(this.#context, id, name, options); + } +} +``` diff --git a/packages/http-client-js/test/scenarios/operation-parameters/union_body.md b/packages/http-client-js/test/scenarios/operation-parameters/union_body.md new file mode 100644 index 00000000000..02fdefefc90 --- /dev/null +++ b/packages/http-client-js/test/scenarios/operation-parameters/union_body.md @@ -0,0 +1,92 @@ +# Should emit an operation that has body property parameter + +```tsp +@service +namespace Test; + +enum LR { + left, + right, +} +enum UD { + up, + down, +} + +model EnumsOnlyCases { + /** This should be receive/send the left variant */ + lr: LR | UD; + + /** This should be receive/send the up variant */ + ud: UD | UD; +} + +@post op send(prop: EnumsOnlyCases): void; +``` + +## Operation + +```ts src/api/testClientOperations.ts function send +export async function send( + client: TestClientContext, + prop: EnumsOnlyCases, + options?: SendOptions, +): Promise { + const path = parse("/").expand({}); + + const httpRequestOptions = { + headers: {}, + body: { + prop: jsonEnumsOnlyCasesToTransportTransform(prop), + }, + }; + + const response = await client.pathUnchecked(path).post(httpRequestOptions); + + if (typeof options?.operationOptions?.onResponse === "function") { + options?.operationOptions?.onResponse(response); + } + + if (+response.status === 204 && !response.body) { + return; + } + + throw createRestError(response); +} +``` + +## Options + +The options bag should like all the optional parameters of the operation + +```ts src/api/testClientOperations.ts interface SendOptions +export interface SendOptions extends OperationOptions {} +``` + +## Client + +```ts src/testClient.ts class TestClient +export class TestClient { + #context: TestClientContext; + + constructor(endpoint: string, options?: TestClientOptions) { + this.#context = createTestClientContext(endpoint, options); + } + async send(prop: EnumsOnlyCases, options?: SendOptions) { + return send(this.#context, prop, options); + } +} +``` + +```ts src/models/serializers.ts function jsonEnumsOnlyCasesToTransportTransform +export function jsonEnumsOnlyCasesToTransportTransform(input_?: EnumsOnlyCases | null): any { + if (!input_) { + return input_ as any; + } + + return { + lr: input_.lr, + ud: input_.ud, + }!; +} +``` diff --git a/packages/http-client-js/test/scenarios/operation-parameters/with_body_property.md b/packages/http-client-js/test/scenarios/operation-parameters/with_body_property.md new file mode 100644 index 00000000000..24beb31e6da --- /dev/null +++ b/packages/http-client-js/test/scenarios/operation-parameters/with_body_property.md @@ -0,0 +1,72 @@ +# Should emit an operation that has a @body explicitly set. + +```tsp +@service +namespace Test; + +model Widget { + id: string; + name: string; + age?: string; +} + +@post op create(@body widget: Widget, @header foo?: string): void; +``` + +## Operation + +The operation has has no required parameters so options and client should be the only ones in the signature + +```ts src/api/testClientOperations.ts function create +export async function create( + client: TestClientContext, + widget: Widget, + options?: CreateOptions, +): Promise { + const path = parse("/").expand({}); + + const httpRequestOptions = { + headers: { + ...(options?.foo && { foo: options?.foo }), + }, + body: jsonWidgetToTransportTransform(widget), + }; + + const response = await client.pathUnchecked(path).post(httpRequestOptions); + + if (typeof options?.operationOptions?.onResponse === "function") { + options?.operationOptions?.onResponse(response); + } + + if (+response.status === 204 && !response.body) { + return; + } + + throw createRestError(response); +} +``` + +## Options + +The options bag should like all the optional parameters of the operation + +```ts src/api/testClientOperations.ts interface CreateOptions +export interface CreateOptions extends OperationOptions { + foo?: string; +} +``` + +## Client + +```ts src/testClient.ts class TestClient +export class TestClient { + #context: TestClientContext; + + constructor(endpoint: string, options?: TestClientOptions) { + this.#context = createTestClientContext(endpoint, options); + } + async create(widget: Widget, options?: CreateOptions) { + return create(this.#context, widget, options); + } +} +``` diff --git a/packages/http-client-js/test/scenarios/operation-parameters/with_body_root.md b/packages/http-client-js/test/scenarios/operation-parameters/with_body_root.md new file mode 100644 index 00000000000..fdec032908a --- /dev/null +++ b/packages/http-client-js/test/scenarios/operation-parameters/with_body_root.md @@ -0,0 +1,74 @@ +# Should emit an operation that has a @bodyRoot set. + +```tsp +@service +namespace Test; + +model Widget { + id: string; + name: string; + age?: string; + @header foo?: string; +} + +@post op create(@bodyRoot widget: Widget): void; +``` + +## Operation + +The operation has has no required parameters so options and client should be the only ones in the signature + +```ts src/api/testClientOperations.ts function create +export async function create( + client: TestClientContext, + widget: Widget, + options?: CreateOptions, +): Promise { + const path = parse("/").expand({}); + + const httpRequestOptions = { + headers: { + ...(options?.foo && { foo: options?.foo }), + }, + body: jsonWidgetToTransportTransform(widget), + }; + + const response = await client.pathUnchecked(path).post(httpRequestOptions); + + if (typeof options?.operationOptions?.onResponse === "function") { + options?.operationOptions?.onResponse(response); + } + + if (+response.status === 204 && !response.body) { + return; + } + + throw createRestError(response); +} +``` + +## Options + +The options bag should like all the optional parameters of the operation + +```ts src/api/testClientOperations.ts interface CreateOptions +export interface CreateOptions extends OperationOptions { + age?: string; + foo?: string; +} +``` + +## Client + +```ts src/testClient.ts class TestClient +export class TestClient { + #context: TestClientContext; + + constructor(endpoint: string, options?: TestClientOptions) { + this.#context = createTestClientContext(endpoint, options); + } + async create(widget: Widget, options?: CreateOptions) { + return create(this.#context, widget, options); + } +} +``` diff --git a/packages/http-client-js/test/scenarios/serializers/arrays.md b/packages/http-client-js/test/scenarios/serializers/arrays.md new file mode 100644 index 00000000000..29e170baea4 --- /dev/null +++ b/packages/http-client-js/test/scenarios/serializers/arrays.md @@ -0,0 +1,199 @@ +# **Handling Serialization and Deserialization of Primitive Array Properties** + +This test verifies that a **primitive array property** (`int32[]`) is correctly handled in the generated TypeScript code. The expected output includes: + +- A `Foo` model with a `myValues` property mapped to `Array`. +- `jsonFooToTransportTransform` and `jsonFooToApplicationTransform` functions that internally use an `arraySerializer`. +- Transformation functions for `int32[]`, ensuring elements are properly processed. + +### **Potential Optimization Consideration** + +Since primitive types **do not require transformation**, generating explicit functions like `jsonArrayInt32ToTransportTransform` and `jsonArrayInt32ToApplicationTransform` might be **redundant**. Optimizing this could **eliminate unnecessary transformations**, reducing the amount of generated code while maintaining correctness. + +## **TypeSpec** + +```tsp +model Foo { + my_values: int32[]; +} +op foo(): Foo; +``` + +## **TypeScript** + +### **Generated Model** + +A TypeScript model representing `Foo` with `myValues` properly typed as `Array`. + +```ts src/models/models.ts interface Foo +export interface Foo { + myValues: Array; +} +``` + +### **Primitive Array Transformation (Consider Optimization)** + +The generated transformation functions iterate over `int32[]` values, but since **no actual transformation occurs**, this code could be **optimized away**. + +```ts src/models/serializers.ts function jsonArrayInt32ToTransportTransform +export function jsonArrayInt32ToTransportTransform(items_?: Array | null): any { + if (!items_) { + return items_ as any; + } + const _transformedArray = []; + + for (const item of items_ ?? []) { + const transformedItem = item as any; + _transformedArray.push(transformedItem); + } + + return _transformedArray as any; +} +``` + +### **Serializer for `Foo`** + +Uses `jsonArrayInt32ToTransportTransform` for `myValues`, though this could be optimized by **directly passing the array** instead of applying a redundant transformation function. + +```ts src/models/serializers.ts function jsonFooToTransportTransform +export function jsonFooToTransportTransform(input_?: Foo | null): any { + if (!input_) { + return input_ as any; + } + + return { + my_values: jsonArrayInt32ToTransportTransform(input_.myValues), + }!; +} +``` + +### **Operation Function for `Foo`** + +Handles the API request, expecting a `Widget` response and applying the correct deserialization function. + +```ts src/api/clientOperations.ts function foo +export async function foo(client: ClientContext, options?: FooOptions): Promise { + const path = parse("/").expand({}); + + const httpRequestOptions = { + headers: {}, + }; + + const response = await client.pathUnchecked(path).get(httpRequestOptions); + + if (typeof options?.operationOptions?.onResponse === "function") { + options?.operationOptions?.onResponse(response); + } + + if (+response.status === 200 && response.headers["content-type"]?.includes("application/json")) { + return jsonFooToApplicationTransform(response.body)!; + } + + throw createRestError(response); +} +``` + +### **Primitive Array Deserialization (Consider Optimization)** + +Again, the transformation logic is redundant for primitive types. Instead of generating a function, the deserializer could **use the array directly**. + +```ts src/models/serializers.ts function jsonArrayInt32ToApplicationTransform +export function jsonArrayInt32ToApplicationTransform(items_?: any): Array { + if (!items_) { + return items_ as any; + } + const _transformedArray = []; + + for (const item of items_ ?? []) { + const transformedItem = item as any; + _transformedArray.push(transformedItem); + } + + return _transformedArray as any; +} +``` + +### **Deserializer for `Foo`** + +Uses the same unnecessary transformation for `myValues`. Optimizing the pipeline could **eliminate this step** for primitive arrays. + +```ts src/models/serializers.ts function jsonFooToApplicationTransform +export function jsonFooToApplicationTransform(input_?: any): Foo { + if (!input_) { + return input_ as any; + } + + return { + myValues: jsonArrayInt32ToApplicationTransform(input_.my_values), + }!; +} +``` + +--- + +# **Handling Serialization and Deserialization of Complex Array Properties** + +This test verifies that **arrays of complex types** (`Bar[]`) are correctly handled in the generated TypeScript code. Unlike primitive arrays, complex types **require transformation functions** to ensure serialization and deserialization are applied correctly. + +## **TypeSpec** + +```tsp +model Bar { + bar_value: string; +} + +model Foo { + my_values: Bar[]; +} +op foo(): Foo; +``` + +## **TypeScript** + +### **Generated Models** + +Defines TypeScript interfaces for `Foo` and `Bar`, ensuring `myValues` is properly typed as `Array`. + +```ts src/models/models.ts interface Foo +export interface Foo { + myValues: Array; +} +``` + +```ts src/models/models.ts interface Bar +export interface Bar { + barValue: string; +} +``` + +### **Serializer for `Foo`** + +Uses `jsonArrayBarToTransportTransform` to serialize each `Bar` instance inside `myValues`, ensuring proper transformation of complex objects. + +```ts src/models/serializers.ts function jsonFooToTransportTransform +export function jsonFooToTransportTransform(input_?: Foo | null): any { + if (!input_) { + return input_ as any; + } + + return { + my_values: jsonArrayBarToTransportTransform(input_.myValues), + }!; +} +``` + +### **Deserializer for `Foo`** + +Similarly, the deserializer converts each `Bar` instance in `myValues` back into an application model using `jsonArrayBarToApplicationTransform`. + +```ts src/models/serializers.ts function jsonFooToApplicationTransform +export function jsonFooToApplicationTransform(input_?: any): Foo { + if (!input_) { + return input_ as any; + } + + return { + myValues: jsonArrayBarToApplicationTransform(input_.my_values), + }!; +} +``` diff --git a/packages/http-client-js/test/scenarios/serializers/basic_model.md b/packages/http-client-js/test/scenarios/serializers/basic_model.md new file mode 100644 index 00000000000..aa14e54ca73 --- /dev/null +++ b/packages/http-client-js/test/scenarios/serializers/basic_model.md @@ -0,0 +1,115 @@ +# Should Generate a Type, Serializer, and Deserializer for a Simple Model + +## TypeSpec + +This TypeSpec block defines a simple model, Foo, containing two properties: name (a string) and age (an integer). The foo operation returns an instance of Foo, ensuring that the generated TypeScript code includes the correct type definitions and transformation functions. + +```tsp +model Foo { + name: string; + age: int32; +} +op foo(): Foo; +``` + +## TypeScript + +### Interface Definition (Foo) + +The test expects a TypeScript interface Foo to be generated in src/models/models.ts, preserving the original properties from the TypeSpec definition. + +```ts src/models/models.ts interface Foo +export interface Foo { + name: string; + age: number; +} +``` + +### Serializer (jsonFooToTransportTransform) + +This function should correctly transform a Foo instance into a transport-friendly format, ensuring all properties are properly mapped. + +```ts src/models/serializers.ts function jsonFooToTransportTransform +export function jsonFooToTransportTransform(input_?: Foo | null): any { + if (!input_) { + return input_ as any; + } + + return { + name: input_.name, + age: input_.age, + }!; +} +``` + +### Deserializer (jsonFooToApplicationTransform) + +This function should correctly reconstruct a Foo instance from a transport-friendly representation, ensuring all properties are properly mapped back. + +```ts src/models/serializers.ts function jsonFooToApplicationTransform +export function jsonFooToApplicationTransform(input_?: any): Foo { + if (!input_) { + return input_ as any; + } + + return { + name: input_.name, + age: input_.age, + }!; +} +``` + +# Should Call Nested Serializers and Deserializers for Model Properties + +## TypeSpec + +This TypeSpec block defines two models, Foo and Bar, where Foo includes a nested reference to Bar. The foo operation returns either a Foo or a Bar, testing how the serialization and deserialization logic handles model properties that reference other models. + +```tsp +model Bar { + address: string; +} + +model Foo { + name: string; + age: int32; + bar: Bar; +} +op foo(): Foo | Bar; +``` + +## TypeScript + +### Serializer for Foo (jsonFooToTransportTransform) + +This function should transform Foo into a transport-friendly format while ensuring that the bar property is serialized using the jsonBarToTransportTransform function. + +```ts src/models/serializers.ts function jsonFooToTransportTransform +export function jsonFooToTransportTransform(input_?: Foo | null): any { + if (!input_) { + return input_ as any; + } + + return { + name: input_.name, + age: input_.age, + bar: jsonBarToTransportTransform(input_.bar), + }!; +} +``` + +### Serializer for Bar (jsonBarToTransportTransform) + +This function should transform a Bar instance into its transport format, correctly mapping its properties. + +```ts src/models/serializers.ts function jsonBarToTransportTransform +export function jsonBarToTransportTransform(input_?: Bar | null): any { + if (!input_) { + return input_ as any; + } + + return { + address: input_.address, + }!; +} +``` diff --git a/packages/http-client-js/test/scenarios/serializers/basic_model_wire_name.md b/packages/http-client-js/test/scenarios/serializers/basic_model_wire_name.md new file mode 100644 index 00000000000..14781401e2a --- /dev/null +++ b/packages/http-client-js/test/scenarios/serializers/basic_model_wire_name.md @@ -0,0 +1,50 @@ +# Should generate the correct properties and handle deserialization for models with different client than wire names + +## TypeSpec + +```tsp +model Foo { + element_name: string; + age: int32; +} +op foo(): Foo; +``` + +## TypeScript + +Should generate a type for type with name `Foo` in the `src/models/models.ts` file along with a serializer named `jsonFooToTransportTransform` and a deserializer named `jsonFooToApplicationTransform` in `src/models/serializers.ts`. +The generated model should have property names using camelCasing. Serializer should return these properties with the same name defined in the spec while the deserializer +should return these properties with the same name as the generated model (camelCase) + +```ts src/models/models.ts interface Foo +export interface Foo { + elementName: string; + age: number; +} +``` + +```ts src/models/serializers.ts function jsonFooToTransportTransform +export function jsonFooToTransportTransform(input_?: Foo | null): any { + if (!input_) { + return input_ as any; + } + + return { + element_name: input_.elementName, + age: input_.age, + }!; +} +``` + +```ts src/models/serializers.ts function jsonFooToApplicationTransform +export function jsonFooToApplicationTransform(input_?: any): Foo { + if (!input_) { + return input_ as any; + } + + return { + elementName: input_.element_name, + age: input_.age, + }!; +} +``` diff --git a/packages/http-client-js/test/scenarios/serializers/discriminated_union.md b/packages/http-client-js/test/scenarios/serializers/discriminated_union.md new file mode 100644 index 00000000000..e1242c3d738 --- /dev/null +++ b/packages/http-client-js/test/scenarios/serializers/discriminated_union.md @@ -0,0 +1,86 @@ +# Should handle a discriminated union + +## Typespec + +```tsp +@service +namespace Test; +@discriminator("kind") +union WidgetData { + kind0: WidgetData0, + kind1: WidgetData1, +} + +model WidgetData0 { + kind: "kind0"; + fooProp: string; +} + +model WidgetData1 { + kind: "kind1"; + start: utcDateTime; + end?: utcDateTime; +} + +@get +op get(): WidgetData; + +@put +op put(@body body: WidgetData): void; +``` + +## Typescript + +```ts src/models/serializers.ts function jsonWidgetDataToTransportTransform +export function jsonWidgetDataToTransportTransform(input_?: WidgetData | null): any { + if (!input_) { + return input_ as any; + } + return jsonWidgetDataToTransportDiscriminator(input_); +} +``` + +```ts src/models/serializers.ts function jsonWidgetDataToApplicationTransform +export function jsonWidgetDataToApplicationTransform(input_?: any): WidgetData { + if (!input_) { + return input_ as any; + } + return jsonWidgetDataToApplicationDiscriminator(input_); +} +``` + +```ts src/models/serializers.ts function jsonWidgetDataToTransportDiscriminator +export function jsonWidgetDataToTransportDiscriminator(input_?: WidgetData): any { + if (!input_) { + return input_ as any; + } + const discriminatorValue = input_.kind; + if (discriminatorValue === "kind0") { + return jsonWidgetData0ToTransportTransform(input_ as any)!; + } + + if (discriminatorValue === "kind1") { + return jsonWidgetData1ToTransportTransform(input_ as any)!; + } + console.warn(`Received unknown kind: ` + discriminatorValue); + return input_ as any; +} +``` + +```ts src/models/serializers.ts function jsonWidgetDataToApplicationDiscriminator +export function jsonWidgetDataToApplicationDiscriminator(input_?: any): WidgetData { + if (!input_) { + return input_ as any; + } + const discriminatorValue = input_.kind; + if (discriminatorValue === "kind0") { + return jsonWidgetData0ToApplicationTransform(input_ as any)!; + } + + if (discriminatorValue === "kind1") { + return jsonWidgetData1ToApplicationTransform(input_ as any)!; + } + console.warn(`Received unknown kind: ` + discriminatorValue); + return input_ as any; +} +``` diff --git a/packages/http-client-js/test/scenarios/serializers/discriminated_union_spread.md b/packages/http-client-js/test/scenarios/serializers/discriminated_union_spread.md new file mode 100644 index 00000000000..e86e85d4526 --- /dev/null +++ b/packages/http-client-js/test/scenarios/serializers/discriminated_union_spread.md @@ -0,0 +1,119 @@ +# Should handle a discriminated union + +## Typespec + +```tsp +@service +namespace Test; +@discriminator("kind") +union WidgetData { + kind0: WidgetData0, + kind1: WidgetData1, +} + +model WidgetData0 { + kind: "kind0"; + fooProp: string; +} + +model WidgetData1 { + kind: "kind1"; + start: utcDateTime; + end?: utcDateTime; +} + +@doc("The model spread Record") +model SpreadRecordForDiscriminatedUnion { + @doc("The name property") + name: string; + + ...Record; +} + +@get +op get(): SpreadRecordForDiscriminatedUnion; + +@put +op put(@body body: SpreadRecordForDiscriminatedUnion): void; +``` + +## Typescript + +```ts src/models/serializers.ts function jsonSpreadRecordForDiscriminatedUnionToTransportTransform +export function jsonSpreadRecordForDiscriminatedUnionToTransportTransform( + input_?: SpreadRecordForDiscriminatedUnion | null, +): any { + if (!input_) { + return input_ as any; + } + + return { + ...jsonRecordWidgetDataToTransportTransform(input_.additionalProperties), + + name: input_.name, + }!; +} +``` + +```ts src/models/serializers.ts function jsonSpreadRecordForDiscriminatedUnionToApplicationTransform +export function jsonSpreadRecordForDiscriminatedUnionToApplicationTransform( + input_?: any, +): SpreadRecordForDiscriminatedUnion { + if (!input_) { + return input_ as any; + } + + return { + additionalProperties: jsonRecordWidgetDataToApplicationTransform( + (({ name, ...rest }) => rest)(input_), + ), + + name: input_.name, + }!; +} +``` + +```ts src/models/serializers.ts function jsonWidgetDataToTransportTransform +export function jsonWidgetDataToTransportTransform(input_?: WidgetData | null): any { + if (!input_) { + return input_ as any; + } + return jsonWidgetDataToTransportDiscriminator(input_); +} +``` + +```ts src/models/serializers.ts function jsonWidgetDataToApplicationDiscriminator +export function jsonWidgetDataToApplicationDiscriminator(input_?: any): WidgetData { + if (!input_) { + return input_ as any; + } + const discriminatorValue = input_.kind; + if (discriminatorValue === "kind0") { + return jsonWidgetData0ToApplicationTransform(input_ as any)!; + } + + if (discriminatorValue === "kind1") { + return jsonWidgetData1ToApplicationTransform(input_ as any)!; + } + console.warn(`Received unknown kind: ` + discriminatorValue); + return input_ as any; +} +``` + +```ts src/models/serializers.ts function jsonWidgetDataToTransportDiscriminator +export function jsonWidgetDataToTransportDiscriminator(input_?: WidgetData): any { + if (!input_) { + return input_ as any; + } + const discriminatorValue = input_.kind; + if (discriminatorValue === "kind0") { + return jsonWidgetData0ToTransportTransform(input_ as any)!; + } + + if (discriminatorValue === "kind1") { + return jsonWidgetData1ToTransportTransform(input_ as any)!; + } + console.warn(`Received unknown kind: ` + discriminatorValue); + return input_ as any; +} +``` diff --git a/packages/http-client-js/test/scenarios/serializers/model_date_time.md b/packages/http-client-js/test/scenarios/serializers/model_date_time.md new file mode 100644 index 00000000000..ab9d76c05aa --- /dev/null +++ b/packages/http-client-js/test/scenarios/serializers/model_date_time.md @@ -0,0 +1,65 @@ +# skip:Should generate the correct properties and handle deserialization for models with utcDateTime type + +Defaults to rfc7231 encoding + +## TypeSpec + +```tsp +model Foo { + created_on: utcDateTime; +} + +op foo(): Foo; +``` + +## TypeScript + +Should generate a type for type with name `Foo` in the `src/models/models.ts` file along with a serializer named `jsonFooToTransportTransform` and a deserializer named `jsonFooToApplicationTransform` in `src/models/serializers.ts`. +The generated model should have a property `createdOn` of type `Date`, the generated serializer `jsonFooToTransportTransform` should convert a Date into a string. + +```ts src/models/models.ts interface Foo +export interface Foo { + createdOn: Date; +} +``` + +```ts src/models/serializers.ts function jsonFooToTransportTransform +export function jsonFooToTransportTransform(item: Foo): any { + return { + created_on: dateRfc3339Serializer(item.createdOn), + }; +} +``` + +```ts src/models/serializers.ts function jsonFooToApplicationTransform +export function jsonFooToApplicationTransform(item: any): Foo { + return { + createdOn: dateDeserializer(item.created_on), + }; +} +``` + +# skip:Should generate the correct properties and handle deserialization for models with utcDateTime type with rfc7231 encoding + +## TypeSpec + +```tsp +model Foo { + @encode("rfc7231") + created_on: utcDateTime; +} +op foo(): Foo; +``` + +## TypeScript + +Should generate a type for type with name `Foo` in the `src/models/models.ts` file along with a serializer named `jsonFooToTransportTransform` and a deserializer named `jsonFooToApplicationTransform` in `src/models/serializers.ts`. +The generated model should have a property `createdOn` of type `Date`, the generated serializer `jsonFooToTransportTransform` should convert a Date into a string using `toUTCString()` + +```ts src/models/serializers.ts function jsonFooToTransportTransform +export function jsonFooToTransportTransform(item: Foo): any { + return { + created_on: dateRfc7231Serializer(item.createdOn), + }; +} +``` diff --git a/packages/http-client-js/test/scenarios/serializers/multipart.md b/packages/http-client-js/test/scenarios/serializers/multipart.md new file mode 100644 index 00000000000..16af4e0baca --- /dev/null +++ b/packages/http-client-js/test/scenarios/serializers/multipart.md @@ -0,0 +1,66 @@ +# Should emit serializer and deserializer correctly for properties with primitive array type + +## Typespec + +```tsp +namespace Test; +model FileSpecificContentType extends File { + filename: string; + contentType: "image/jpg"; +} + +model FileWithHttpPartSpecificContentTypeRequest { + profileImage: HttpPart; +} + +@post op create( + @header contentType: "multipart/form-data", + @multipartBody body: FileWithHttpPartSpecificContentTypeRequest, +): NoContentResponse; +``` + +## TypeScript + +Should generate a model `FileWithHttpPartSpecificContentTypeRequest` and also a `fileWithHttpPartSpecificContentTypeRequestToTransport` and `fileWithHttpPartSpecificContentTypeRequestToApplication`. + +```ts src/models/models.ts interface FileSpecificContentType +export interface FileSpecificContentType extends File { + filename: string; + contentType: "image/jpg"; +} +``` + +```ts src/models/models.ts interface FileWithHttpPartSpecificContentTypeRequest +export interface FileWithHttpPartSpecificContentTypeRequest { + profileImage: FileSpecificContentType; +} +``` + +```ts src/api/testClientOperations.ts function create +export async function create( + client: TestClientContext, + body: FileWithHttpPartSpecificContentTypeRequest, + options?: CreateOptions, +): Promise { + const path = parse("/").expand({}); + + const httpRequestOptions = { + headers: { + "content-type": options?.contentType ?? "multipart/form-data", + }, + body: [createFilePartDescriptor("profileImage", body.profileImage, "image/jpg")], + }; + + const response = await client.pathUnchecked(path).post(httpRequestOptions); + + if (typeof options?.operationOptions?.onResponse === "function") { + options?.operationOptions?.onResponse(response); + } + + if (+response.status === 204 && !response.body) { + return; + } + + throw createRestError(response); +} +``` diff --git a/packages/http-client-js/test/scenarios/serializers/polymorphic_single_level_inheritance.md b/packages/http-client-js/test/scenarios/serializers/polymorphic_single_level_inheritance.md new file mode 100644 index 00000000000..b69bb3c4149 --- /dev/null +++ b/packages/http-client-js/test/scenarios/serializers/polymorphic_single_level_inheritance.md @@ -0,0 +1,164 @@ +# Should Emit Correct Serializers and Deserializers for Polymorphic Models with Records, Arrays, and References" + +## Typespec + +The following TypeSpec block defines a service and several data models that serve as the foundation for our serializer tests. It starts with a base model, Bird, which uses a discriminator (kind) to support polymorphic behavior. Several derived modelsโ€”SeaGull, Sparrow, Goose, and Eagleโ€”are declared, each with a specific kind value to enable precise runtime type dispatching. Notably, the Eagle model includes additional complex properties (an array, a record, and a singular instance of Bird) to thoroughly test serialization of nested and compound types. This specification also exposes an HTTP GET endpoint returning a polymorphic Bird instance, ensuring that the generated TypeScript serializers handle these scenarios correctly. + +```tsp +@service({ + title: "Test Service", +}) +namespace Test; + +@doc("This is base model for polymorphic single level inheritance with a discriminator.") +@discriminator("kind") +model Bird { + kind: string; + wingspan: int32; +} + +@doc("The second level model in polymorphic single level inheritance.") +model SeaGull extends Bird { + kind: "seagull"; +} + +@doc("The second level model in polymorphic single level inheritance.") +model Sparrow extends Bird { + kind: "sparrow"; +} + +@doc("The second level model in polymorphic single level inheritance.") +model Goose extends Bird { + kind: "goose"; +} + +@doc("The second level model in polymorphic single levels inheritance which contains references to other polymorphic instances.") +model Eagle extends Bird { + kind: "eagle"; + friends?: Bird[]; + hate?: Record; + partner?: Bird; +} + +@route("/model") +@get +op getModel(): Bird; +``` + +**Expectation for `jsonBirdToTransportDiscriminator`:** +This function should select the appropriate transport transformation based on the `kind` property of the `Bird` instance. It checks for specific kinds ("seagull", "sparrow", "goose", "eagle") and delegates to the corresponding transform. If the kind is unknown, it logs a warning and returns a fallback value. + +```ts src/models/serializers.ts function jsonBirdToTransportDiscriminator +export function jsonBirdToTransportDiscriminator(input_?: Bird): any { + if (!input_) { + return input_ as any; + } + const discriminatorValue = input_.kind; + if (discriminatorValue === "seagull") { + return jsonSeaGullToTransportTransform(input_ as any)!; + } + + if (discriminatorValue === "sparrow") { + return jsonSparrowToTransportTransform(input_ as any)!; + } + + if (discriminatorValue === "goose") { + return jsonGooseToTransportTransform(input_ as any)!; + } + + if (discriminatorValue === "eagle") { + return jsonEagleToTransportTransform(input_ as any)!; + } + console.warn(`Received unknown kind: ` + discriminatorValue); + return input_ as any; +} +``` + +**Expectation for `jsonBirdToTransportTransform`:** +This function should transform a basic `Bird` instance by mapping its core properties (`kind` and `wingspan`) to the transport format. + +```ts src/models/serializers.ts function jsonBirdToTransportTransform +export function jsonBirdToTransportTransform(input_?: Bird | null): any { + if (!input_) { + return input_ as any; + } + + return { + ...jsonBirdToTransportDiscriminator(input_), + kind: input_.kind, + wingspan: input_.wingspan, + }!; +} +``` + +**Expectation for `jsonSeaGullToTransportTransform`:** +For a `SeaGull` instance, the serializer should extend the base transformation provided by `jsonBirdToApplicationTransform` and then explicitly include all properties from SeaGull in this case it is just the `kind` . + +```ts src/models/serializers.ts function jsonSeaGullToTransportTransform +export function jsonSeaGullToTransportTransform(input_?: SeaGull | null): any { + if (!input_) { + return input_ as any; + } + + return { + kind: input_.kind, + wingspan: input_.wingspan, + }!; +} +``` + +**Expectation for `jsonSparrowToTransportTransform`:** +Similarly, the serializer for a `Sparrow` instance should build upon the base Bird transformation and add the `kind` property accordingly. + +```ts src/models/serializers.ts function jsonSparrowToTransportTransform +export function jsonSparrowToTransportTransform(input_?: Sparrow | null): any { + if (!input_) { + return input_ as any; + } + + return { + kind: input_.kind, + wingspan: input_.wingspan, + }!; +} +``` + +**Expectation for `jsonGooseToTransportTransform`:** +This function should transform a `Goose` instance by reusing the base Bird transformation and explicitly setting the `kind` property. + +```ts src/models/serializers.ts function jsonGooseToTransportTransform +export function jsonGooseToTransportTransform(input_?: Goose | null): any { + if (!input_) { + return input_ as any; + } + + return { + kind: input_.kind, + wingspan: input_.wingspan, + }!; +} +``` + +**Expectation for `jsonEagleToTransportTransform`:** +The serializer for an `Eagle` instance is more complex due to additional properties. It must: + +- Extend the base transformation (`jsonBirdToApplicationTransform`), +- Transform the `friends` property (an array of `Bird` instances) using `jsonArrayBirdToTransportTransform`, +- Transform the `hate` property (a primitive Record of `Bird` instances) using `jsonRecordBirdToTransportTransform`, and +- Transform the `partner` property (a single `Bird` instance) using `jsonBirdToApplicationTransform`. + +```ts src/models/serializers.ts function jsonEagleToTransportTransform +export function jsonEagleToTransportTransform(input_?: Eagle | null): any { + if (!input_) { + return input_ as any; + } + + return { + kind: input_.kind, + friends: jsonArrayBirdToTransportTransform(input_.friends), + hate: jsonRecordBirdToTransportTransform(input_.hate), + partner: jsonBirdToTransportTransform(input_.partner), + wingspan: input_.wingspan, + }!; +} +``` diff --git a/packages/http-client-js/test/scenarios/serializers/record.md b/packages/http-client-js/test/scenarios/serializers/record.md new file mode 100644 index 00000000000..e94905cfb20 --- /dev/null +++ b/packages/http-client-js/test/scenarios/serializers/record.md @@ -0,0 +1,101 @@ +# Should emit serializer and deserializer correctly for properties with primitive Record type + +## Typespec + +```tsp +model Foo { + my_values: Record; +} + +op foo(): Foo; +``` + +## TypeScript + +Should generate a model `Foo` and also a `jsonFooToTransportTransform` and `jsonFooToApplicationTransform` functions that call the `recordSerializer` internally. + +```ts src/models/models.ts interface Foo +export interface Foo { + myValues: Record; +} +``` + +```ts src/models/serializers.ts function jsonFooToTransportTransform +export function jsonFooToTransportTransform(input_?: Foo | null): any { + if (!input_) { + return input_ as any; + } + + return { + my_values: jsonRecordInt32ToTransportTransform(input_.myValues), + }!; +} +``` + +```ts src/models/serializers.ts function jsonFooToApplicationTransform +export function jsonFooToApplicationTransform(input_?: any): Foo { + if (!input_) { + return input_ as any; + } + + return { + myValues: jsonRecordInt32ToApplicationTransform(input_.my_values), + }!; +} +``` + +# Should emit serializer and deserializer correctly for properties with complex array type + +## Typespec + +```tsp +model Bar { + bar_value: string; +} + +model Foo { + my_values: Record; +} + +op foo(): Foo | Bar; +``` + +## TypeScript + +Should generate models `Foo` and `Bar` and also a `jsonFooToTransportTransform`, `jsonFooToApplicationTransform`, `barToTransport` and `barToApplication` functions that call the `recordSerializer` passing `barToTransport` or `barDeserialize` as the serialization callback. + +```ts src/models/models.ts interface Foo +export interface Foo { + myValues: Record; +} +``` + +```ts src/models/models.ts interface Bar +export interface Bar { + barValue: string; +} +``` + +```ts src/models/serializers.ts function jsonFooToTransportTransform +export function jsonFooToTransportTransform(input_?: Foo | null): any { + if (!input_) { + return input_ as any; + } + + return { + my_values: jsonRecordBarToTransportTransform(input_.myValues), + }!; +} +``` + +```ts src/models/serializers.ts function jsonFooToApplicationTransform +export function jsonFooToApplicationTransform(input_?: any): Foo { + if (!input_) { + return input_ as any; + } + + return { + myValues: jsonRecordBarToApplicationTransform(input_.my_values), + }!; +} +``` diff --git a/packages/http-client-js/test/scenarios/serializers/scalars.md b/packages/http-client-js/test/scenarios/serializers/scalars.md new file mode 100644 index 00000000000..3bfe45638cb --- /dev/null +++ b/packages/http-client-js/test/scenarios/serializers/scalars.md @@ -0,0 +1,33 @@ +# Should generate a serializer for a declared utcDateTime scalar + +Should generate a Type Alias for each of the utcDateTime taking into account the encoding. All should generate + +## TypeSpec + +```tsp +scalar MyDate extends utcDateTime; +@encode("rfc3339") +scalar MyUtcDate extends utcDateTime; +@encode("rfc7231") +scalar MyIsoDate extends utcDateTime; +@encode("unixTimestamp", int32) +scalar MyUnixDate extends utcDateTime; + +op foo(a: MyDate, b: MyUtcDate, c: MyIsoDate, d: MyUnixDate): void; +``` + +## TypeScript + +```ts src/models/models.ts +export type String = string; + +export type MyDate = Date; + +export type UtcDateTime = Date; + +export type MyUtcDate = Date; + +export type MyIsoDate = Date; + +export type MyUnixDate = Date; +``` diff --git a/packages/http-client-js/test/scenarios/serializers/spread.md b/packages/http-client-js/test/scenarios/serializers/spread.md new file mode 100644 index 00000000000..7369bfe383a --- /dev/null +++ b/packages/http-client-js/test/scenarios/serializers/spread.md @@ -0,0 +1,75 @@ +# Should handle spread with multiple parameters + +## Typespec + +```tsp +@service({ + title: "Test Service", +}) +namespace Test; +alias MultipleRequestParameters = { + @path + id: string; + + @header + `x-ms-test-header`: string; + + /** required string */ + requiredString: string; + + /** optional int */ + optionalInt?: int32; + + /** required int */ + requiredIntList: int32[]; + + /** optional string */ + optionalStringList?: string[]; +}; + +@put +op spreadWithMultipleParameters(...MultipleRequestParameters): NoContentResponse; +``` + +## Typescript + +When spreading a model an anonymous model created in the type graph, the emitted operation should have the serializer expression inline. No serializer function for this operation is expected in src/models/serializers.ts + +```ts src/api/testClientOperations.ts function spreadWithMultipleParameters +export async function spreadWithMultipleParameters( + client: TestClientContext, + id: string, + xMsTestHeader: string, + requiredString: string, + requiredIntList: Array, + options?: SpreadWithMultipleParametersOptions, +): Promise { + const path = parse("/{id}").expand({ + id: id, + }); + + const httpRequestOptions = { + headers: { + "x-ms-test-header": xMsTestHeader, + }, + body: { + requiredString: requiredString, + optionalInt: options?.optionalInt, + requiredIntList: jsonArrayInt32ToTransportTransform(requiredIntList), + optionalStringList: jsonArrayStringToTransportTransform(options?.optionalStringList), + }, + }; + + const response = await client.pathUnchecked(path).put(httpRequestOptions); + + if (typeof options?.operationOptions?.onResponse === "function") { + options?.operationOptions?.onResponse(response); + } + + if (+response.status === 204 && !response.body) { + return; + } + + throw createRestError(response); +} +``` diff --git a/packages/http-client-js/test/scenarios/serializers/string_union.md b/packages/http-client-js/test/scenarios/serializers/string_union.md new file mode 100644 index 00000000000..67f09f0db31 --- /dev/null +++ b/packages/http-client-js/test/scenarios/serializers/string_union.md @@ -0,0 +1,61 @@ +# Should handle operations with string union input + +## Typespec + +```tsp +@service({ + title: "Test Service", +}) +namespace Test; +union ServerExtensibleEnum { + string, + EnumValue1: "value1", +} + +@post +op unionEnumName(@body body: ServerExtensibleEnum): NoContentResponse; +``` + +## Typescript + +```ts src/models/models.ts type ServerExtensibleEnum +export type ServerExtensibleEnum = string | "value1"; +``` + +```ts src/api/testClientOperations.ts function unionEnumName +export async function unionEnumName( + client: TestClientContext, + body: ServerExtensibleEnum, + options?: UnionEnumNameOptions, +): Promise { + const path = parse("/").expand({}); + + const httpRequestOptions = { + headers: {}, + body: jsonServerExtensibleEnumToTransportTransform(body), + }; + + const response = await client.pathUnchecked(path).post(httpRequestOptions); + + if (typeof options?.operationOptions?.onResponse === "function") { + options?.operationOptions?.onResponse(response); + } + + if (+response.status === 204 && !response.body) { + return; + } + + throw createRestError(response); +} +``` + +```ts src/models/serializers.ts function jsonServerExtensibleEnumToTransportTransform +export function jsonServerExtensibleEnumToTransportTransform( + input_?: ServerExtensibleEnum | null, +): any { + if (!input_) { + return input_ as any; + } + return input_; +} +``` diff --git a/packages/http-client-js/test/scenarios/server/default_url.md b/packages/http-client-js/test/scenarios/server/default_url.md new file mode 100644 index 00000000000..9e467ee39c1 --- /dev/null +++ b/packages/http-client-js/test/scenarios/server/default_url.md @@ -0,0 +1,45 @@ +# Should create a constructor with a default endpoint and an optional endpoint parameter. + +## Spec + +This spec defines a service with a server that has a default endpoint + +```tsp +@service +@server("https://example.org/api") +namespace Test; + +op foo(): void; +``` + +## Client + +The client uses the optional endpoint if available or the default endpoint + +```ts src/api/testClientContext.ts function createTestClientContext +export function createTestClientContext(options?: TestClientOptions): TestClientContext { + const params: Record = { + endpoint: options?.endpoint ?? "https://example.org/api", + }; + const resolvedEndpoint = "{endpoint}".replace(/{([^}]+)}/g, (_, key) => + key in params + ? String(params[key]) + : (() => { + throw new Error(`Missing parameter: ${key}`); + })(), + ); + return getClient(resolvedEndpoint, { + ...options, + }); +} +``` + +## Client options + +Since there is a default url, the endpoint parameter is not required, but optional in case customers want to point to another url. + +```ts src/api/testClientContext.ts interface TestClientOptions +export interface TestClientOptions extends ClientOptions { + endpoint?: string; +} +``` diff --git a/packages/http-client-js/test/scenarios/server/multiple-parameters.md b/packages/http-client-js/test/scenarios/server/multiple-parameters.md new file mode 100644 index 00000000000..76abe52c3fc --- /dev/null +++ b/packages/http-client-js/test/scenarios/server/multiple-parameters.md @@ -0,0 +1,57 @@ +# Should build the server url based on the parametrized host + +## Spec + +This spec defines a server that has a host template and and endpoint to fill it out. + +```tsp +@service({ + title: "Parametrized Endpoint", +}) +@server( + "{endpoint}/server/path/multiple/{apiVersion}", + "Test server with path parameters.", + { + endpoint: url, + apiVersion: string, + } +) +namespace Test; + +op noOperationParams(): NoContentResponse; +``` + +## Client Context + +The client context should use the parameters to build the baseUrl using the template. + +```ts src/api/testClientContext.ts +import { Client, ClientOptions, getClient } from "@typespec/ts-http-runtime"; + +export interface TestClientContext extends Client {} +export interface TestClientOptions extends ClientOptions { + endpoint?: string; +} +export function createTestClientContext( + endpoint: string, + apiVersion: string, + options?: TestClientOptions, +): TestClientContext { + const params: Record = { + endpoint: endpoint, + apiVersion: apiVersion, + }; + const resolvedEndpoint = "{endpoint}/server/path/multiple/{apiVersion}".replace( + /{([^}]+)}/g, + (_, key) => + key in params + ? String(params[key]) + : (() => { + throw new Error(`Missing parameter: ${key}`); + })(), + ); + return getClient(resolvedEndpoint, { + ...options, + }); +} +``` diff --git a/packages/http-client-js/test/scenarios/server/no-server.md b/packages/http-client-js/test/scenarios/server/no-server.md new file mode 100644 index 00000000000..65594ab7e3f --- /dev/null +++ b/packages/http-client-js/test/scenarios/server/no-server.md @@ -0,0 +1,37 @@ +# Should create a constructor with a default endpoint and an optional endpoint parameter. + +## Spec + +This spec defines a service with a server that has a default endpoint + +```tsp +@service +namespace Test; + +op foo(): void; +``` + +## Client + +The client has a required positional parameter for endpoint. + +```ts src/api/testClientContext.ts function createTestClientContext +export function createTestClientContext( + endpoint: string, + options?: TestClientOptions, +): TestClientContext { + const params: Record = { + endpoint: endpoint, + }; + const resolvedEndpoint = "{endpoint}".replace(/{([^}]+)}/g, (_, key) => + key in params + ? String(params[key]) + : (() => { + throw new Error(`Missing parameter: ${key}`); + })(), + ); + return getClient(resolvedEndpoint, { + ...options, + }); +} +``` diff --git a/packages/http-client-js/test/scenarios/server/parametrized-endpoint.md b/packages/http-client-js/test/scenarios/server/parametrized-endpoint.md new file mode 100644 index 00000000000..86e5c88cf12 --- /dev/null +++ b/packages/http-client-js/test/scenarios/server/parametrized-endpoint.md @@ -0,0 +1,53 @@ +# Should build the server url based on the parametrized host + +## Spec + +This spec defines a server that has a host template and and endpoint to fill it out. + +```tsp +@service({ + title: "Parametrized Endpoint", +}) +@server( + "{foo}/server/path/multiple", + "Test server with path parameters.", + { + @doc("Pass in http://localhost:3000 for endpoint.") + foo: url, + } +) +namespace Test; + +op noOperationParams(): NoContentResponse; +``` + +## Client Context + +The client context should use the parameters to build the baseUrl using the template. + +```ts src/api/testClientContext.ts +import { Client, ClientOptions, getClient } from "@typespec/ts-http-runtime"; + +export interface TestClientContext extends Client {} +export interface TestClientOptions extends ClientOptions { + endpoint?: string; +} +export function createTestClientContext( + foo: string, + options?: TestClientOptions, +): TestClientContext { + const params: Record = { + foo: foo, + }; + const resolvedEndpoint = "{foo}/server/path/multiple".replace(/{([^}]+)}/g, (_, key) => + key in params + ? String(params[key]) + : (() => { + throw new Error(`Missing parameter: ${key}`); + })(), + ); + return getClient(resolvedEndpoint, { + ...options, + }); +} +``` diff --git a/packages/http-client-js/test/test-host.ts b/packages/http-client-js/test/test-host.ts new file mode 100644 index 00000000000..975b0f976cf --- /dev/null +++ b/packages/http-client-js/test/test-host.ts @@ -0,0 +1,76 @@ +import { Diagnostic } from "@typespec/compiler"; +import { + BasicTestRunner, + createTestHost, + createTestWrapper, + expectDiagnosticEmpty, +} from "@typespec/compiler/testing"; +import { HttpTestLibrary } from "@typespec/http/testing"; +import { RestTestLibrary } from "@typespec/rest/testing"; +import { join, relative } from "path"; +import { HttpClientJavascriptEmitterTestLibrary } from "../src/testing/index.js"; + +export async function createHttpClientJsTestHost() { + return createTestHost({ + libraries: [HttpClientJavascriptEmitterTestLibrary, HttpTestLibrary, RestTestLibrary], + }); +} + +export async function createHttpClientJavascriptEmitterTestRunner() { + const host = await createHttpClientJsTestHost(); + + return createTestWrapper(host, { + autoImports: ["@typespec/http", "@typespec/rest"], + autoUsings: ["TypeSpec.Http", "TypeSpec.Rest"], + compilerOptions: { + noEmit: false, + emit: ["@typespec/http-client-js"], + }, + }); +} + +const emitterOutputDir = join("tsp-output", "http-client-js"); + +export async function emitWithDiagnostics( + code: string, +): Promise<[Record, readonly Diagnostic[]]> { + const runner = await createHttpClientJavascriptEmitterTestRunner(); + await runner.compileAndDiagnose(code, { + outputDir: "tsp-output", + }); + const result = await readFilesRecursively(emitterOutputDir, runner); + return [result, runner.program.diagnostics]; +} + +async function readFilesRecursively( + dir: string, + runner: BasicTestRunner, +): Promise> { + const entries = await runner.program.host.readDir(dir); + const result: Record = {}; + + for (const entry of entries) { + const fullPath = join(dir, entry); + const stat = await runner.program.host.stat(fullPath); + + if (stat.isDirectory()) { + // Recursively read files in the directory + const nestedFiles = await readFilesRecursively(fullPath, runner); + Object.assign(result, nestedFiles); + } else if (stat.isFile()) { + // Read the file + // Read the file and store it with a relative path + const relativePath = relative(emitterOutputDir, fullPath); + const fileContent = await runner.program.host.readFile(fullPath); + result[relativePath] = fileContent.text; + } + } + + return result; +} + +export async function emit(code: string): Promise> { + const [result, diagnostics] = await emitWithDiagnostics(code); + expectDiagnosticEmpty(diagnostics); + return result; +} diff --git a/packages/http-client-js/tsconfig.json b/packages/http-client-js/tsconfig.json new file mode 100644 index 00000000000..2e830d9f475 --- /dev/null +++ b/packages/http-client-js/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "target": "es2022", + "skipLibCheck": true, + "isolatedModules": true, + "jsx": "preserve", + "emitDeclarationOnly": true, + "outDir": "dist" + }, + "include": ["src/**/*.ts", "src/**/*.tsx"], + "exclude": ["node_modules", "dist", "test/e2e/generated/**/*.ts"] +} diff --git a/packages/http-client-js/tsconfig.test.json b/packages/http-client-js/tsconfig.test.json new file mode 100644 index 00000000000..7529441668e --- /dev/null +++ b/packages/http-client-js/tsconfig.test.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "dist-test" + }, + "include": ["test/e2e/**/*"] +} diff --git a/packages/http-client-js/tsp-spector-coverage-javascript-standard.json b/packages/http-client-js/tsp-spector-coverage-javascript-standard.json new file mode 100644 index 00000000000..e20fbd7c982 --- /dev/null +++ b/packages/http-client-js/tsp-spector-coverage-javascript-standard.json @@ -0,0 +1,674 @@ +[ + { + "scenariosMetadata": { + "commit": "e857c8e9c6764fb491743b925ee1b8129bd7a43b", + "version": "0.1.0-alpha.9", + "packageName": "@typespec/http-specs" + }, + "results": { + "Routes_InInterface": "pass", + "Routes_fixed": "pass", + "Routes_PathParameters_templateOnly": "pass", + "Routes_PathParameters_explicit": "pass", + "Routes_PathParameters_annotationOnly": "pass", + "Routes_PathParameters_ReservedExpansion_template": "pass", + "Routes_PathParameters_ReservedExpansion_annotation": "pass", + "Routes_PathParameters_SimpleExpansion_Standard_primitive": "not-implemented", + "Routes_PathParameters_SimpleExpansion_Standard_array": "not-implemented", + "Routes_PathParameters_SimpleExpansion_Standard_record": "not-implemented", + "Routes_PathParameters_SimpleExpansion_Explode_primitive": "not-implemented", + "Routes_PathParameters_SimpleExpansion_Explode_array": "not-implemented", + "Routes_PathParameters_SimpleExpansion_Explode_record": "not-implemented", + "Routes_PathParameters_PathExpansion_Standard_primitive": "not-implemented", + "Routes_PathParameters_PathExpansion_Standard_array": "not-implemented", + "Routes_PathParameters_PathExpansion_Standard_record": "not-implemented", + "Routes_PathParameters_PathExpansion_Explode_primitive": "not-implemented", + "Routes_PathParameters_PathExpansion_Explode_array": "not-implemented", + "Routes_PathParameters_PathExpansion_Explode_record": "not-implemented", + "Routes_PathParameters_LabelExpansion_Standard_primitive": "not-implemented", + "Routes_PathParameters_LabelExpansion_Standard_array": "not-implemented", + "Routes_PathParameters_LabelExpansion_Standard_record": "not-implemented", + "Routes_PathParameters_LabelExpansion_Explode_primitive": "not-implemented", + "Routes_PathParameters_LabelExpansion_Explode_array": "not-implemented", + "Routes_PathParameters_LabelExpansion_Explode_record": "not-implemented", + "Routes_PathParameters_MatrixExpansion_Standard_primitive": "not-implemented", + "Routes_PathParameters_MatrixExpansion_Standard_array": "not-implemented", + "Routes_PathParameters_MatrixExpansion_Standard_record": "not-implemented", + "Routes_PathParameters_MatrixExpansion_Explode_primitive": "not-implemented", + "Routes_PathParameters_MatrixExpansion_Explode_array": "not-implemented", + "Routes_PathParameters_MatrixExpansion_Explode_record": "not-implemented", + "Routes_QueryParameters_templateOnly": "pass", + "Routes_QueryParameters_explicit": "pass", + "Routes_QueryParameters_annotationOnly": "pass", + "Routes_QueryParameters_QueryExpansion_Standard_primitive": "not-implemented", + "Routes_QueryParameters_QueryExpansion_Standard_array": "not-implemented", + "Routes_QueryParameters_QueryExpansion_Standard_record": "not-implemented", + "Routes_QueryParameters_QueryExpansion_Explode_primitive": "not-implemented", + "Routes_QueryParameters_QueryExpansion_Explode_array": "not-implemented", + "Routes_QueryParameters_QueryExpansion_Explode_record": "not-implemented", + "Routes_QueryParameters_QueryContinuation_Standard_primitive": "pass", + "Routes_QueryParameters_QueryContinuation_Standard_array": "pass", + "Routes_QueryParameters_QueryContinuation_Standard_record": "pass", + "Routes_QueryParameters_QueryContinuation_Explode_primitive": "not-implemented", + "Routes_QueryParameters_QueryContinuation_Explode_array": "pass", + "Routes_QueryParameters_QueryContinuation_Explode_record": "pass", + "SpecialWords_ModelProperties_sameAsModel": "pass", + "SpecialWords_Models_and": "pass", + "SpecialWords_Models_as": "pass", + "SpecialWords_Models_assert": "pass", + "SpecialWords_Models_async": "pass", + "SpecialWords_Models_await": "pass", + "SpecialWords_Models_break": "pass", + "SpecialWords_Models_class": "pass", + "SpecialWords_Models_constructor": "pass", + "SpecialWords_Models_continue": "pass", + "SpecialWords_Models_def": "pass", + "SpecialWords_Models_del": "pass", + "SpecialWords_Models_elif": "pass", + "SpecialWords_Models_else": "pass", + "SpecialWords_Models_except": "pass", + "SpecialWords_Models_exec": "pass", + "SpecialWords_Models_finally": "pass", + "SpecialWords_Models_for": "pass", + "SpecialWords_Models_from": "pass", + "SpecialWords_Models_global": "pass", + "SpecialWords_Models_if": "pass", + "SpecialWords_Models_import": "pass", + "SpecialWords_Models_in": "pass", + "SpecialWords_Models_is": "pass", + "SpecialWords_Models_lambda": "pass", + "SpecialWords_Models_not": "pass", + "SpecialWords_Models_or": "pass", + "SpecialWords_Models_pass": "pass", + "SpecialWords_Models_raise": "pass", + "SpecialWords_Models_return": "pass", + "SpecialWords_Models_try": "pass", + "SpecialWords_Models_while": "pass", + "SpecialWords_Models_with": "pass", + "SpecialWords_Models_yield": "pass", + "SpecialWords_Operations_and": "pass", + "SpecialWords_Operations_as": "pass", + "SpecialWords_Operations_assert": "pass", + "SpecialWords_Operations_async": "pass", + "SpecialWords_Operations_await": "pass", + "SpecialWords_Operations_break": "pass", + "SpecialWords_Operations_class": "pass", + "SpecialWords_Operations_constructor": "pass", + "SpecialWords_Operations_continue": "pass", + "SpecialWords_Operations_def": "pass", + "SpecialWords_Operations_del": "pass", + "SpecialWords_Operations_elif": "pass", + "SpecialWords_Operations_else": "pass", + "SpecialWords_Operations_except": "pass", + "SpecialWords_Operations_exec": "pass", + "SpecialWords_Operations_finally": "pass", + "SpecialWords_Operations_for": "pass", + "SpecialWords_Operations_from": "pass", + "SpecialWords_Operations_global": "pass", + "SpecialWords_Operations_if": "pass", + "SpecialWords_Operations_import": "pass", + "SpecialWords_Operations_in": "pass", + "SpecialWords_Operations_is": "pass", + "SpecialWords_Operations_lambda": "pass", + "SpecialWords_Operations_not": "pass", + "SpecialWords_Operations_or": "pass", + "SpecialWords_Operations_pass": "pass", + "SpecialWords_Operations_raise": "pass", + "SpecialWords_Operations_return": "pass", + "SpecialWords_Operations_try": "pass", + "SpecialWords_Operations_while": "pass", + "SpecialWords_Operations_with": "pass", + "SpecialWords_Operations_yield": "pass", + "SpecialWords_Parameters_and": "pass", + "SpecialWords_Parameters_as": "pass", + "SpecialWords_Parameters_assert": "pass", + "SpecialWords_Parameters_async": "pass", + "SpecialWords_Parameters_await": "pass", + "SpecialWords_Parameters_break": "pass", + "SpecialWords_Parameters_class": "pass", + "SpecialWords_Parameters_constructor": "pass", + "SpecialWords_Parameters_continue": "pass", + "SpecialWords_Parameters_def": "pass", + "SpecialWords_Parameters_del": "pass", + "SpecialWords_Parameters_elif": "pass", + "SpecialWords_Parameters_else": "pass", + "SpecialWords_Parameters_except": "pass", + "SpecialWords_Parameters_exec": "pass", + "SpecialWords_Parameters_finally": "pass", + "SpecialWords_Parameters_for": "pass", + "SpecialWords_Parameters_from": "pass", + "SpecialWords_Parameters_global": "pass", + "SpecialWords_Parameters_if": "pass", + "SpecialWords_Parameters_import": "pass", + "SpecialWords_Parameters_in": "pass", + "SpecialWords_Parameters_is": "pass", + "SpecialWords_Parameters_lambda": "pass", + "SpecialWords_Parameters_not": "pass", + "SpecialWords_Parameters_or": "pass", + "SpecialWords_Parameters_pass": "pass", + "SpecialWords_Parameters_raise": "pass", + "SpecialWords_Parameters_return": "pass", + "SpecialWords_Parameters_try": "pass", + "SpecialWords_Parameters_while": "pass", + "SpecialWords_Parameters_with": "pass", + "SpecialWords_Parameters_yield": "pass", + "SpecialWords_Parameters_cancellationToken": "pass", + "Authentication_ApiKey_invalid": "not-implemented", + "Authentication_ApiKey_valid": "pass", + "Authentication_OAuth2_valid": "not-implemented", + "Authentication_OAuth2_invalid": "not-implemented", + "Authentication_Union_validKey": "pass", + "Authentication_Union_validToken": "not-implemented", + "Encode_Bytes_Query_default": "not-implemented", + "Encode_Bytes_Query_base64": "pass", + "Encode_Bytes_Query_base64url": "pass", + "Encode_Bytes_Query_base64urlArray": "pass", + "Encode_Bytes_Property_default": "pass", + "Encode_Bytes_Property_base64": "pass", + "Encode_Bytes_Property_base64url": "pass", + "Encode_Bytes_Property_base64urlArray": "pass", + "Encode_Bytes_Header_default": "pass", + "Encode_Bytes_Header_base64": "pass", + "Encode_Bytes_Header_base64url": "pass", + "Encode_Bytes_Header_base64urlArray": "pass", + "Encode_Bytes_RequestBody_default": "not-implemented", + "Encode_Bytes_RequestBody_base64": "not-implemented", + "Encode_Bytes_RequestBody_base64url": "not-implemented", + "Encode_Bytes_RequestBody_customContentType": "pass", + "Encode_Bytes_RequestBody_octetStream": "pass", + "Encode_Bytes_ResponseBody_default": "pass", + "Encode_Bytes_ResponseBody_base64": "pass", + "Encode_Bytes_ResponseBody_base64url": "pass", + "Encode_Bytes_ResponseBody_customContentType": "not-implemented", + "Encode_Bytes_ResponseBody_octetStream": "not-implemented", + "Encode_Datetime_Query_default": "pass", + "Encode_Datetime_Query_rfc3339": "pass", + "Encode_Datetime_Query_rfc7231": "pass", + "Encode_Datetime_Query_unixTimestamp": "pass", + "Encode_Datetime_Query_unixTimestampArray": "pass", + "Encode_Datetime_Property_default": "pass", + "Encode_Datetime_Property_rfc3339": "pass", + "Encode_Datetime_Property_rfc7231": "pass", + "Encode_Datetime_Property_unixTimestamp": "not-implemented", + "Encode_Datetime_Property_unixTimestampArray": "not-implemented", + "Encode_Datetime_Header_default": "pass", + "Encode_Datetime_Header_rfc3339": "pass", + "Encode_Datetime_Header_rfc7231": "pass", + "Encode_Datetime_Header_unixTimestamp": "not-implemented", + "Encode_Datetime_Header_unixTimestampArray": "not-implemented", + "Encode_Datetime_ResponseHeader_default": "pass", + "Encode_Datetime_ResponseHeader_rfc3339": "pass", + "Encode_Datetime_ResponseHeader_rfc7231": "not-implemented", + "Encode_Datetime_ResponseHeader_unixTimestamp": "not-implemented", + "Encode_Duration_Property_default": "pass", + "Encode_Duration_Property_floatSeconds": "not-implemented", + "Encode_Duration_Property_float64Seconds": "not-implemented", + "Encode_Duration_Property_int32Seconds": "not-implemented", + "Encode_Duration_Property_iso8601": "pass", + "Encode_Duration_Property_floatSecondsArray": "not-implemented", + "Encode_Duration_Query_default": "pass", + "Encode_Duration_Query_iso8601": "pass", + "Encode_Duration_Query_int32Seconds": "not-implemented", + "Encode_Duration_Query_int32SecondsArray": "not-implemented", + "Encode_Duration_Query_floatSeconds": "not-implemented", + "Encode_Duration_Query_float64Seconds": "not-implemented", + "Encode_Duration_Header_default": "pass", + "Encode_Duration_Header_iso8601": "pass", + "Encode_Duration_Header_int32Seconds": "not-implemented", + "Encode_Duration_Header_floatSeconds": "not-implemented", + "Encode_Duration_Header_float64Seconds": "not-implemented", + "Encode_Duration_Header_iso8601Array": "pass", + "Encode_Numeric_Property_safeintAsString": "not-implemented", + "Encode_Numeric_Property_uint32AsStringOptional": "not-implemented", + "Encode_Numeric_Property_uint8AsString": "not-implemented", + "Parameters_Basic_ExplicitBody_simple": "pass", + "Parameters_Basic_ImplicitBody_simple": "pass", + "Parameters_BodyOptionality_requiredExplicit": "pass", + "Parameters_BodyOptionality_OptionalExplicit": "pass", + "Parameters_BodyOptionality_requiredImplicit": "pass", + "Parameters_CollectionFormat_Query_multi": "pass", + "Parameters_CollectionFormat_Query_csv": "pass", + "Parameters_CollectionFormat_Query_ssv": "not-implemented", + "Parameters_CollectionFormat_Query_tsv": "not-implemented", + "Parameters_CollectionFormat_Query_pipes": "not-implemented", + "Parameters_CollectionFormat_Header_csv": "pass", + "Parameters_Spread_Model_spreadAsRequestBody": "pass", + "Parameters_Spread_Model_spreadCompositeRequestOnlyWithBody": "pass", + "Parameters_Spread_Model_spreadCompositeRequestWithoutBody": "pass", + "Parameters_Spread_Model_spreadCompositeRequest": "pass", + "Parameters_Spread_Model_spreadCompositeRequestMix": "pass", + "Parameters_Spread_Alias_spreadAsRequestBody": "pass", + "Parameters_Spread_Alias_spreadAsRequestParameter": "pass", + "Parameters_Spread_Alias_spreadWithMultipleParameters": "pass", + "Parameters_Spread_Alias_spreadParameterWithInnerModel": "pass", + "Parameters_Spread_Alias_spreadParameterWithInnerAlias": "pass", + "Payload_ContentNegotiation_SameBody": "not-implemented", + "Payload_ContentNegotiation_DifferentBody": "not-implemented", + "Payload_JsonMergePatch_createResource": "pass", + "Payload_JsonMergePatch_updateResource": "pass", + "Payload_JsonMergePatch_updateOptionalResource": "not-implemented", + "Payload_MediaType_StringBody_sendAsText": "not-implemented", + "Payload_MediaType_StringBody_getAsText": "not-implemented", + "Payload_MediaType_StringBody_sendAsJson": "pass", + "Payload_MediaType_StringBody_getAsJson": "pass", + "Payload_MultiPart_FormData_basic": "not-implemented", + "Payload_MultiPart_FormData_fileArrayAndBasic": "not-implemented", + "Payload_MultiPart_FormData_jsonPart": "not-implemented", + "Payload_MultiPart_FormData_binaryArrayParts": "not-implemented", + "Payload_MultiPart_FormData_multiBinaryParts": "not-implemented", + "Payload_MultiPart_FormData_checkFileNameAndContentType": "not-implemented", + "Payload_MultiPart_FormData_anonymousModel": "not-implemented", + "Payload_MultiPart_FormData_HttpParts_ContentType_imageJpegContentType": "not-implemented", + "Payload_MultiPart_FormData_HttpParts_ContentType_requiredContentType": "not-implemented", + "Payload_MultiPart_FormData_HttpParts_ContentType_optionalContentType": "not-implemented", + "Payload_MultiPart_FormData_HttpParts_jsonArrayAndFileArray": "not-implemented", + "Payload_MultiPart_FormData_HttpParts_NonString_float": "not-implemented", + "Payload_Pageable_ServerDrivenPagination_link": "not-implemented", + "Payload_Xml_SimpleModelValue_get": "not-implemented", + "Payload_Xml_SimpleModelValue_put": "not-implemented", + "Payload_Xml_ModelWithSimpleArraysValue_get": "not-implemented", + "Payload_Xml_ModelWithSimpleArraysValue_put": "not-implemented", + "Payload_Xml_ModelWithArrayOfModelValue_get": "not-implemented", + "Payload_Xml_ModelWithArrayOfModelValue_put": "not-implemented", + "Payload_Xml_ModelWithOptionalFieldValue_get": "not-implemented", + "Payload_Xml_ModelWithOptionalFieldValue_put": "not-implemented", + "Payload_Xml_ModelWithAttributesValue_get": "not-implemented", + "Payload_Xml_ModelWithAttributesValue_put": "not-implemented", + "Payload_Xml_ModelWithUnwrappedArrayValue_get": "not-implemented", + "Payload_Xml_ModelWithUnwrappedArrayValue_put": "not-implemented", + "Payload_Xml_ModelWithRenamedArraysValue_get": "not-implemented", + "Payload_Xml_ModelWithRenamedArraysValue_put": "not-implemented", + "Payload_Xml_ModelWithRenamedFieldsValue_get": "not-implemented", + "Payload_Xml_ModelWithRenamedFieldsValue_put": "not-implemented", + "Payload_Xml_ModelWithEmptyArrayValue_get": "not-implemented", + "Payload_Xml_ModelWithEmptyArrayValue_put": "not-implemented", + "Payload_Xml_ModelWithTextValue_get": "not-implemented", + "Payload_Xml_ModelWithTextValue_put": "not-implemented", + "Payload_Xml_ModelWithDictionaryValue_get": "not-implemented", + "Payload_Xml_ModelWithDictionaryValue_put": "not-implemented", + "Payload_Xml_ModelWithEncodedNamesValue_get": "not-implemented", + "Payload_Xml_ModelWithEncodedNamesValue_put": "not-implemented", + "Response_StatusCodeRange_errorResponseStatusCodeInRange": "not-implemented", + "Response_StatusCodeRange_errorResponseStatusCode404": "not-implemented", + "SpecialHeaders_ConditionalRequest_postIfUnmodifiedSince": "pass", + "SpecialHeaders_ConditionalRequest_headIfModifiedSince": "pass", + "SpecialHeaders_ConditionalRequest_postIfMatch": "not-implemented", + "SpecialHeaders_ConditionalRequest_postIfNoneMatch": "not-implemented", + "SpecialHeaders_Repeatability_immediateSuccess": "pass", + "Type_Array_Int32Value_get": "pass", + "Type_Array_Int32Value_put": "pass", + "Type_Array_Int64Value_get": "pass", + "Type_Array_Int64Value_put": "not-implemented", + "Type_Array_BooleanValue_get": "pass", + "Type_Array_BooleanValue_put": "pass", + "Type_Array_StringValue_get": "pass", + "Type_Array_StringValue_put": "pass", + "Type_Array_Float32Value_get": "pass", + "Type_Array_Float32Value_put": "pass", + "Type_Array_DatetimeValue_get": "pass", + "Type_Array_DatetimeValue_put": "pass", + "Type_Array_DurationValue_get": "pass", + "Type_Array_DurationValue_put": "pass", + "Type_Array_UnknownValue_get": "pass", + "Type_Array_UnknownValue_put": "pass", + "Type_Array_ModelValue_get": "pass", + "Type_Array_ModelValue_put": "pass", + "Type_Array_NullableFloatValue_get": "pass", + "Type_Array_NullableFloatValue_put": "pass", + "Type_Array_NullableBooleanValue_get": "pass", + "Type_Array_NullableBooleanValue_put": "pass", + "Type_Array_NullableInt32Value_get": "pass", + "Type_Array_NullableInt32Value_put": "pass", + "Type_Array_NullableStringValue_get": "pass", + "Type_Array_NullableStringValue_put": "pass", + "Type_Array_NullableModelValue_get": "pass", + "Type_Array_NullableModelValue_put": "pass", + "Type_Dictionary_Int32Value_get": "pass", + "Type_Dictionary_Int32Value_put": "pass", + "Type_Dictionary_Int64Value_get": "pass", + "Type_Dictionary_Int64Value_put": "not-implemented", + "Type_Dictionary_BooleanValue_get": "pass", + "Type_Dictionary_BooleanValue_put": "pass", + "Type_Dictionary_StringValue_get": "pass", + "Type_Dictionary_StringValue_put": "pass", + "Type_Dictionary_Float32Value_get": "pass", + "Type_Dictionary_Float32Value_put": "pass", + "Type_Dictionary_DatetimeValue_get": "pass", + "Type_Dictionary_DatetimeValue_put": "pass", + "Type_Dictionary_DurationValue_get": "pass", + "Type_Dictionary_DurationValue_put": "pass", + "Type_Dictionary_UnknownValue_get": "pass", + "Type_Dictionary_UnknownValue_put": "pass", + "Type_Dictionary_ModelValue_get": "pass", + "Type_Dictionary_ModelValue_put": "pass", + "Type_Dictionary_RecursiveModelValue_get": "pass", + "Type_Dictionary_RecursiveModelValue_put": "pass", + "Type_Dictionary_NullableFloatValue_get": "pass", + "Type_Dictionary_NullableFloatValue_put": "pass", + "Type_Scalar_String_get": "pass", + "Type_Scalar_String_put": "not-implemented", + "Type_Scalar_Boolean_get": "pass", + "Type_Scalar_Boolean_put": "pass", + "Type_Scalar_Unknown_get": "pass", + "Type_Scalar_Unknown_put": "not-implemented", + "Type_Scalar_DecimalType_responseBody": "pass", + "Type_Scalar_Decimal128Type_responseBody": "pass", + "Type_Scalar_DecimalType_requestBody": "pass", + "Type_Scalar_Decimal128Type_requestBody": "pass", + "Type_Scalar_DecimalType_requestParameter": "pass", + "Type_Scalar_Decimal128Type_requestParameter": "pass", + "Type_Scalar_DecimalVerify_prepareVerify": "pass", + "Type_Scalar_Decimal128Verify_prepareVerify": "pass", + "Type_Scalar_DecimalVerify_verify": "pass", + "Type_Scalar_Decimal128Verify_verify": "pass", + "Type_Union_StringsOnly_get": "pass", + "Type_Union_StringsOnly_send": "pass", + "Type_Union_StringExtensible_get": "pass", + "Type_Union_StringExtensible_send": "pass", + "Type_Union_StringExtensibleNamed_get": "pass", + "Type_Union_StringExtensibleNamed_send": "pass", + "Type_Union_IntsOnly_get": "pass", + "Type_Union_IntsOnly_send": "pass", + "Type_Union_FloatsOnly_get": "pass", + "Type_Union_FloatsOnly_send": "pass", + "Type_Union_ModelsOnly_get": "pass", + "Type_Union_ModelsOnly_send": "pass", + "Type_Union_EnumsOnly_get": "pass", + "Type_Union_EnumsOnly_send": "pass", + "Type_Union_StringAndArray_get": "pass", + "Type_Union_StringAndArray_send": "pass", + "Type_Union_MixedLiterals_get": "pass", + "Type_Union_MixedLiterals_send": "pass", + "Type_Union_MixedTypes_get": "pass", + "Type_Union_MixedTypes_send": "pass", + "Versioning_Added_v1": "not-implemented", + "Versioning_Added_v2": "pass", + "Versioning_Added_InterfaceV2": "pass", + "Versioning_MadeOptional_test": "pass", + "Versioning_Removed_v2": "pass", + "Versioning_Removed_modelV3": "pass", + "Versioning_Removed_modelV3_V2": "pass", + "Versioning_Removed_modelV3_V2preview": "not-implemented", + "Versioning_RenamedFrom_newOp": "pass", + "Versioning_RenamedFrom_NewInterface": "pass", + "Versioning_ReturnTypeChangedFrom_test": "not-implemented", + "Versioning_TypeChangedFrom_test": "pass", + "Authentication_Http_Custom_valid": "not-implemented", + "Authentication_Http_Custom_invalid": "not-implemented", + "Serialization_EncodedName_Json_Property_send": "pass", + "Serialization_EncodedName_Json_Property_get": "pass", + "Server_Endpoint_NotDefined_valid": "pass", + "Server_Path_Multiple_noOperationParams": "pass", + "Server_Path_Multiple_withOperationPathParam": "pass", + "Server_Path_Single_myOp": "pass", + "Server_Versions_NotVersioned_withoutApiVersion": "pass", + "Server_Versions_NotVersioned_withPathApiVersion": "pass", + "Server_Versions_NotVersioned_withQueryApiVersion": "not-implemented", + "Server_Versions_Versioned_withoutApiVersion": "pass", + "Server_Versions_Versioned_withPathApiVersion": "pass", + "Server_Versions_Versioned_withQueryOldApiVersion": "not-implemented", + "Server_Versions_Versioned_withQueryApiVersion": "not-implemented", + "Type_Enum_Extensible_String_getKnownValue": "pass", + "Type_Enum_Extensible_String_putKnownValue": "not-implemented", + "Type_Enum_Extensible_String_getUnknownValue": "pass", + "Type_Enum_Extensible_String_putUnknownValue": "not-implemented", + "Type_Enum_Fixed_String_getKnownValue": "pass", + "Type_Enum_Fixed_String_putKnownValue": "not-implemented", + "Type_Enum_Fixed_String_putUnknownValue": "not-implemented", + "Type_Model_Empty_putEmpty": "pass", + "Type_Model_Empty_getEmpty": "pass", + "Type_Model_Empty_postRoundTripEmpty": "pass", + "Type_Model_Usage_input": "not-implemented", + "Type_Model_Usage_output": "not-implemented", + "Type_Model_Usage_inputAndOutput": "not-implemented", + "Type_Model_Visibility_putReadOnlyModel": "pass", + "Type_Model_Visibility_headModel": "pass", + "Type_Model_Visibility_getModel": "pass", + "Type_Model_Visibility_putModel": "pass", + "Type_Model_Visibility_patchModel": "pass", + "Type_Model_Visibility_postModel": "pass", + "Type_Model_Visibility_deleteModel": "pass", + "Type_Property_AdditionalProperties_ExtendsUnknown_get": "pass", + "Type_Property_AdditionalProperties_ExtendsUnknown_put": "pass", + "Type_Property_AdditionalProperties_ExtendsUnknownDerived_get": "pass", + "Type_Property_AdditionalProperties_ExtendsUnknownDerived_put": "pass", + "Type_Property_AdditionalProperties_ExtendsUnknownDiscriminated_get": "pass", + "Type_Property_AdditionalProperties_ExtendsUnknownDiscriminated_put": "pass", + "Type_Property_AdditionalProperties_IsUnknown_get": "pass", + "Type_Property_AdditionalProperties_IsUnknown_put": "pass", + "Type_Property_AdditionalProperties_IsUnknownDerived_get": "pass", + "Type_Property_AdditionalProperties_IsUnknownDerived_put": "pass", + "Type_Property_AdditionalProperties_IsUnknownDiscriminated_get": "pass", + "Type_Property_AdditionalProperties_IsUnknownDiscriminated_put": "pass", + "Type_Property_AdditionalProperties_ExtendsString_get": "pass", + "Type_Property_AdditionalProperties_ExtendsString_put": "pass", + "Type_Property_AdditionalProperties_IsString_get": "pass", + "Type_Property_AdditionalProperties_IsString_put": "pass", + "Type_Property_AdditionalProperties_ExtendsFloat_get": "pass", + "Type_Property_AdditionalProperties_ExtendsFloat_put": "pass", + "Type_Property_AdditionalProperties_IsFloat_get": "pass", + "Type_Property_AdditionalProperties_IsFloat_put": "pass", + "Type_Property_AdditionalProperties_ExtendsModel_get": "pass", + "Type_Property_AdditionalProperties_ExtendsModel_put": "pass", + "Type_Property_AdditionalProperties_IsModel_get": "pass", + "Type_Property_AdditionalProperties_IsModel_put": "pass", + "Type_Property_AdditionalProperties_ExtendsModelArray_get": "pass", + "Type_Property_AdditionalProperties_ExtendsModelArray_put": "pass", + "Type_Property_AdditionalProperties_IsModelArray_get": "pass", + "Type_Property_AdditionalProperties_IsModelArray_put": "pass", + "Type_Property_AdditionalProperties_SpreadString_get": "pass", + "Type_Property_AdditionalProperties_SpreadString_put": "pass", + "Type_Property_AdditionalProperties_SpreadFloat_get": "pass", + "Type_Property_AdditionalProperties_SpreadFloat_put": "pass", + "Type_Property_AdditionalProperties_SpreadModel_get": "pass", + "Type_Property_AdditionalProperties_SpreadModel_put": "pass", + "Type_Property_AdditionalProperties_SpreadModelArray_get": "pass", + "Type_Property_AdditionalProperties_SpreadModelArray_put": "pass", + "Type_Property_AdditionalProperties_SpreadDifferentString_get": "pass", + "Type_Property_AdditionalProperties_SpreadDifferentString_put": "pass", + "Type_Property_AdditionalProperties_SpreadDifferentFloat_get": "pass", + "Type_Property_AdditionalProperties_SpreadDifferentFloat_put": "pass", + "Type_Property_AdditionalProperties_SpreadDifferentModel_get": "pass", + "Type_Property_AdditionalProperties_SpreadDifferentModel_put": "pass", + "Type_Property_AdditionalProperties_SpreadDifferentModelArray_get": "pass", + "Type_Property_AdditionalProperties_SpreadDifferentModelArray_put": "pass", + "Type_Property_AdditionalProperties_ExtendsDifferentSpreadString_get": "pass", + "Type_Property_AdditionalProperties_ExtendsDifferentSpreadString_put": "pass", + "Type_Property_AdditionalProperties_ExtendsDifferentSpreadFloat_get": "pass", + "Type_Property_AdditionalProperties_ExtendsDifferentSpreadFloat_put": "pass", + "Type_Property_AdditionalProperties_ExtendsDifferentSpreadModel_get": "pass", + "Type_Property_AdditionalProperties_ExtendsDifferentSpreadModel_put": "pass", + "Type_Property_AdditionalProperties_ExtendsDifferentSpreadModelArray_get": "pass", + "Type_Property_AdditionalProperties_ExtendsDifferentSpreadModelArray_put": "pass", + "Type_Property_AdditionalProperties_MultipleSpread_get": "pass", + "Type_Property_AdditionalProperties_MultipleSpread_put": "pass", + "Type_Property_AdditionalProperties_SpreadRecordUnion_get": "pass", + "Type_Property_AdditionalProperties_SpreadRecordUnion_put": "pass", + "Type_Property_AdditionalProperties_SpreadRecordDiscriminatedUnion_get": "pass", + "Type_Property_AdditionalProperties_SpreadRecordDiscriminatedUnion_put": "pass", + "Type_Property_AdditionalProperties_SpreadRecordNonDiscriminatedUnion_get": "pass", + "Type_Property_AdditionalProperties_SpreadRecordNonDiscriminatedUnion_put": "pass", + "Type_Property_AdditionalProperties_SpreadRecordNonDiscriminatedUnion2_get": "pass", + "Type_Property_AdditionalProperties_SpreadRecordNonDiscriminatedUnion2_put": "pass", + "Type_Property_AdditionalProperties_SpreadRecordNonDiscriminatedUnion3_get": "pass", + "Type_Property_AdditionalProperties_SpreadRecordNonDiscriminatedUnion3_put": "pass", + "Type_Property_Nullable_String_getNonNull": "pass", + "Type_Property_Nullable_String_getNull": "pass", + "Type_Property_Nullable_String_patchNonNull": "pass", + "Type_Property_Nullable_String_patchNull": "pass", + "Type_Property_Nullable_Bytes_getNonNull": "pass", + "Type_Property_Nullable_Bytes_getNull": "pass", + "Type_Property_Nullable_Bytes_patchNonNull": "pass", + "Type_Property_Nullable_Bytes_patchNull": "pass", + "Type_Property_Nullable_Datetime_getNonNull": "pass", + "Type_Property_Nullable_Datetime_getNull": "pass", + "Type_Property_Nullable_Datetime_patchNonNull": "pass", + "Type_Property_Nullable_Datetime_patchNull": "pass", + "Type_Property_Nullable_Duration_getNonNull": "pass", + "Type_Property_Nullable_Duration_getNull": "pass", + "Type_Property_Nullable_Duration_patchNonNull": "pass", + "Type_Property_Nullable_Duration_patchNull": "pass", + "Type_Property_Nullable_CollectionsByte_getNonNull": "pass", + "Type_Property_Nullable_CollectionsByte_getNull": "pass", + "Type_Property_Nullable_CollectionsByte_patchNonNull": "pass", + "Type_Property_Nullable_CollectionsByte_patchNull": "pass", + "Type_Property_Nullable_CollectionsModel_getNonNull": "pass", + "Type_Property_Nullable_CollectionsModel_getNull": "pass", + "Type_Property_Nullable_CollectionsModel_patchNonNull": "pass", + "Type_Property_Nullable_CollectionsModel_patchNull": "pass", + "Type_Property_Nullable_CollectionsString_getNonNull": "pass", + "Type_Property_Nullable_CollectionsString_getNull": "pass", + "Type_Property_Nullable_CollectionsString_patchNonNull": "pass", + "Type_Property_Nullable_CollectionsString_patchNull": "pass", + "Type_Property_Optional_String_getDefault": "pass", + "Type_Property_Optional_String_putDefault": "pass", + "Type_Property_Optional_String_getAll": "pass", + "Type_Property_Optional_String_putAll": "pass", + "Type_Property_Optional_Bytes_getDefault": "pass", + "Type_Property_Optional_Bytes_putDefault": "pass", + "Type_Property_Optional_Bytes_getAll": "pass", + "Type_Property_Optional_Bytes_putAll": "pass", + "Type_Property_Optional_Datetime_getDefault": "pass", + "Type_Property_Optional_Datetime_putDefault": "pass", + "Type_Property_Optional_Datetime_getAll": "pass", + "Type_Property_Optional_Datetime_putAll": "pass", + "Type_Property_Optional_Duration_getDefault": "pass", + "Type_Property_Optional_Duration_putDefault": "pass", + "Type_Property_Optional_Duration_getAll": "pass", + "Type_Property_Optional_Duration_putAll": "pass", + "Type_Property_Optional_PlainDate_getDefault": "pass", + "Type_Property_Optional_PlainDate_putDefault": "pass", + "Type_Property_Optional_PlainDate_getAll": "pass", + "Type_Property_Optional_PlainDate_putAll": "pass", + "Type_Property_Optional_PlainTime_getDefault": "pass", + "Type_Property_Optional_PlainTime_putDefault": "pass", + "Type_Property_Optional_PlainTime_getAll": "pass", + "Type_Property_Optional_PlainTime_putAll": "pass", + "Type_Property_Optional_CollectionsByte_getDefault": "pass", + "Type_Property_Optional_CollectionsByte_putDefault": "pass", + "Type_Property_Optional_CollectionsByte_getAll": "pass", + "Type_Property_Optional_CollectionsByte_putAll": "pass", + "Type_Property_Optional_CollectionsModel_getDefault": "pass", + "Type_Property_Optional_CollectionsModel_putDefault": "pass", + "Type_Property_Optional_CollectionsModel_getAll": "pass", + "Type_Property_Optional_CollectionsModel_putAll": "pass", + "Type_Property_Optional_StringLiteral_getDefault": "not-implemented", + "Type_Property_Optional_StringLiteral_putDefault": "not-implemented", + "Type_Property_Optional_StringLiteral_getAll": "not-implemented", + "Type_Property_Optional_StringLiteral_putAll": "not-implemented", + "Type_Property_Optional_IntLiteral_getDefault": "not-implemented", + "Type_Property_Optional_IntLiteral_putDefault": "not-implemented", + "Type_Property_Optional_IntLiteral_getAll": "not-implemented", + "Type_Property_Optional_IntLiteral_putAll": "not-implemented", + "Type_Property_Optional_FloatLiteral_getDefault": "not-implemented", + "Type_Property_Optional_FloatLiteral_putDefault": "not-implemented", + "Type_Property_Optional_FloatLiteral_getAll": "not-implemented", + "Type_Property_Optional_FloatLiteral_putAll": "not-implemented", + "Type_Property_Optional_BooleanLiteral_getDefault": "not-implemented", + "Type_Property_Optional_BooleanLiteral_putDefault": "not-implemented", + "Type_Property_Optional_BooleanLiteral_getAll": "not-implemented", + "Type_Property_Optional_BooleanLiteral_putAll": "not-implemented", + "Type_Property_Optional_UnionStringLiteral_getDefault": "not-implemented", + "Type_Property_Optional_UnionStringLiteral_putDefault": "not-implemented", + "Type_Property_Optional_UnionStringLiteral_getAll": "not-implemented", + "Type_Property_Optional_UnionStringLiteral_putAll": "not-implemented", + "Type_Property_Optional_UnionIntLiteral_getDefault": "not-implemented", + "Type_Property_Optional_UnionIntLiteral_putDefault": "not-implemented", + "Type_Property_Optional_UnionIntLiteral_getAll": "not-implemented", + "Type_Property_Optional_UnionIntLiteral_putAll": "not-implemented", + "Type_Property_Optional_UnionFloatLiteral_getDefault": "not-implemented", + "Type_Property_Optional_UnionFloatLiteral_putDefault": "not-implemented", + "Type_Property_Optional_UnionFloatLiteral_getAll": "not-implemented", + "Type_Property_Optional_UnionFloatLiteral_putAll": "not-implemented", + "Type_Property_Optional_RequiredAndOptional_getRequiredOnly": "pass", + "Type_Property_Optional_RequiredAndOptional_putRequiredOnly": "pass", + "Type_Property_Optional_RequiredAndOptional_getAll": "pass", + "Type_Property_Optional_RequiredAndOptional_putAll": "pass", + "Type_Property_ValueTypes_Boolean_get": "pass", + "Type_Property_ValueTypes_Boolean_put": "pass", + "Type_Property_ValueTypes_String_get": "pass", + "Type_Property_ValueTypes_String_put": "pass", + "Type_Property_ValueTypes_Bytes_get": "pass", + "Type_Property_ValueTypes_Bytes_put": "pass", + "Type_Property_ValueTypes_Int_get": "pass", + "Type_Property_ValueTypes_Int_put": "pass", + "Type_Property_ValueTypes_Float_get": "pass", + "Type_Property_ValueTypes_Float_put": "pass", + "Type_Property_ValueTypes_Decimal_get": "pass", + "Type_Property_ValueTypes_Decimal_put": "pass", + "Type_Property_ValueTypes_Decimal128_get": "pass", + "Type_Property_ValueTypes_Decimal128_put": "pass", + "Type_Property_ValueTypes_Datetime_get": "pass", + "Type_Property_ValueTypes_Datetime_put": "pass", + "Type_Property_ValueTypes_Duration_get": "pass", + "Type_Property_ValueTypes_Duration_put": "pass", + "Type_Property_ValueTypes_Enum_get": "pass", + "Type_Property_ValueTypes_Enum_put": "pass", + "Type_Property_ValueTypes_ExtensibleEnum_get": "pass", + "Type_Property_ValueTypes_ExtensibleEnum_put": "pass", + "Type_Property_ValueTypes_Model_get": "not-implemented", + "Type_Property_ValueTypes_Model_put": "not-implemented", + "Type_Property_ValueTypes_CollectionsString_get": "pass", + "Type_Property_ValueTypes_CollectionsString_put": "pass", + "Type_Property_ValueTypes_CollectionsInt_get": "not-implemented", + "Type_Property_ValueTypes_CollectionsInt_put": "not-implemented", + "Type_Property_ValueTypes_CollectionsModel_get": "not-implemented", + "Type_Property_ValueTypes_CollectionsModel_put": "not-implemented", + "Type_Property_ValueTypes_DictionaryString_get": "not-implemented", + "Type_Property_ValueTypes_DictionaryString_put": "not-implemented", + "Type_Property_ValueTypes_Never_get": "not-implemented", + "Type_Property_ValueTypes_Never_put": "not-implemented", + "Type_Property_ValueTypes_UnknownString_get": "not-implemented", + "Type_Property_ValueTypes_UnknownString_put": "not-implemented", + "Type_Property_ValueTypes_UnknownInt_get": "not-implemented", + "Type_Property_ValueTypes_UnknownInt_put": "not-implemented", + "Type_Property_ValueTypes_UnknownDict_get": "not-implemented", + "Type_Property_ValueTypes_UnknownDict_put": "not-implemented", + "Type_Property_ValueTypes_UnknownArray_get": "not-implemented", + "Type_Property_ValueTypes_UnknownArray_put": "not-implemented", + "Type_Property_ValueTypes_StringLiteral_get": "not-implemented", + "Type_Property_ValueTypes_StringLiteral_put": "not-implemented", + "Type_Property_ValueTypes_IntLiteral_get": "not-implemented", + "Type_Property_ValueTypes_IntLiteral_put": "not-implemented", + "Type_Property_ValueTypes_FloatLiteral_get": "not-implemented", + "Type_Property_ValueTypes_FloatLiteral_put": "not-implemented", + "Type_Property_ValueTypes_BooleanLiteral_get": "not-implemented", + "Type_Property_ValueTypes_BooleanLiteral_put": "not-implemented", + "Type_Property_ValueTypes_UnionStringLiteral_get": "not-implemented", + "Type_Property_ValueTypes_UnionStringLiteral_put": "not-implemented", + "Type_Property_ValueTypes_UnionIntLiteral_get": "not-implemented", + "Type_Property_ValueTypes_UnionIntLiteral_put": "not-implemented", + "Type_Property_ValueTypes_UnionFloatLiteral_get": "not-implemented", + "Type_Property_ValueTypes_UnionFloatLiteral_put": "not-implemented", + "Type_Property_ValueTypes_UnionEnumValue_get": "not-implemented", + "Type_Property_ValueTypes_UnionEnumValue_put": "not-implemented", + "Type_Model_Inheritance_EnumDiscriminator_getExtensibleModel": "pass", + "Type_Model_Inheritance_EnumDiscriminator_putExtensibleModel": "pass", + "Type_Model_Inheritance_EnumDiscriminator_getFixedModel": "pass", + "Type_Model_Inheritance_EnumDiscriminator_putFixedModel": "pass", + "Type_Model_Inheritance_EnumDiscriminator_getExtensibleModelMissingDiscriminator": "pass", + "Type_Model_Inheritance_EnumDiscriminator_getExtensibleModelWrongDiscriminator": "pass", + "Type_Model_Inheritance_EnumDiscriminator_getFixedModelMissingDiscriminator": "pass", + "Type_Model_Inheritance_EnumDiscriminator_getFixedModelWrongDiscriminator": "pass", + "Type_Model_Inheritance_NestedDiscriminator_getModel": "pass", + "Type_Model_Inheritance_NestedDiscriminator_putModel": "pass", + "Type_Model_Inheritance_NestedDiscriminator_getRecursiveModel": "pass", + "Type_Model_Inheritance_NestedDiscriminator_putRecursiveModel": "pass", + "Type_Model_Inheritance_NestedDiscriminator_getMissingDiscriminator": "pass", + "Type_Model_Inheritance_NestedDiscriminator_getWrongDiscriminator": "pass", + "Type_Model_Inheritance_NotDiscriminated_postValid": "pass", + "Type_Model_Inheritance_NotDiscriminated_getValid": "pass", + "Type_Model_Inheritance_NotDiscriminated_putValid": "pass", + "Type_Model_Inheritance_Recursive_put": "pass", + "Type_Model_Inheritance_Recursive_get": "pass", + "Type_Model_Inheritance_SingleDiscriminator_getModel": "pass", + "Type_Model_Inheritance_SingleDiscriminator_putModel": "pass", + "Type_Model_Inheritance_SingleDiscriminator_getRecursiveModel": "pass", + "Type_Model_Inheritance_SingleDiscriminator_putRecursiveModel": "pass", + "Type_Model_Inheritance_SingleDiscriminator_getMissingDiscriminator": "pass", + "Type_Model_Inheritance_SingleDiscriminator_getWrongDiscriminator": "pass", + "Type_Model_Inheritance_SingleDiscriminator_getLegacyModel": "pass" + }, + "createdAt": "2025-02-27T23:55:15.970Z" + } +] diff --git a/packages/http-client-js/vitest.config.js b/packages/http-client-js/vitest.config.js new file mode 100644 index 00000000000..831199deee9 --- /dev/null +++ b/packages/http-client-js/vitest.config.js @@ -0,0 +1,23 @@ +import { babel } from "@rollup/plugin-babel"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["test/**/*.test.ts", "test/**/*.test.tsx"], + exclude: ["test/**/*.d.ts"], + passWithNoTests: true, + }, + esbuild: { + jsx: "preserve", + sourcemap: "both", + }, + plugins: [ + babel({ + inputSourceMap: true, + sourceMaps: "both", + babelHelpers: "bundled", + extensions: [".ts", ".tsx"], + presets: ["@babel/preset-typescript", "@alloy-js/babel-preset"], + }), + ], +}); diff --git a/packages/http-client/package.json b/packages/http-client/package.json index 85983d9275b..92c8734ae4a 100644 --- a/packages/http-client/package.json +++ b/packages/http-client/package.json @@ -32,13 +32,16 @@ "eslint": "^9.18.0", "prettier": "~3.4.2", "typescript": "~5.7.3", - "vitest": "^3.0.5" + "vitest": "^3.0.5", + "concurrently": "^9.1.2" }, "scripts": { "build-src": "babel src -d dist/src --extensions .ts,.tsx", "build": "tsc -p . && npm run build-src", - "watch": "tsc --watch", - "build:tsp": "tsp compile . --no-emit", + "clean": "rimraf ./dist", + "watch-src": "babel src -d dist/src --extensions .ts,.tsx --watch", + "watch-tsc": "tsc -p . --watch", + "watch": "concurrently --kill-others \"npm run watch-tsc\" \"npm run watch-src\"", "test": "vitest run", "lint": "eslint src/ test/ --report-unused-disable-directives --max-warnings=0", "lint:fix": "eslint . --report-unused-disable-directives --fix", diff --git a/packages/http-client/src/client-library.ts b/packages/http-client/src/client-library.ts index 677f6848a54..9e463c66f78 100644 --- a/packages/http-client/src/client-library.ts +++ b/packages/http-client/src/client-library.ts @@ -1,7 +1,7 @@ -import { Enum, Model, Namespace, Operation, Union } from "@typespec/compiler"; -import { unsafe_mutateSubgraph, unsafe_Mutator } from "@typespec/compiler/experimental"; +import { Enum, Model, Namespace, Union } from "@typespec/compiler"; +import { unsafe_Mutator } from "@typespec/compiler/experimental"; import { $ } from "@typespec/compiler/experimental/typekit"; -import { Client, ClientOperation, InternalClient } from "./interfaces.js"; +import { Client, InternalClient } from "./interfaces.js"; import { reportDiagnostic } from "./lib.js"; import { collectDataTypes } from "./utils/type-collector.js"; @@ -130,14 +130,23 @@ function visitClient( ); // Now store the prepared operations - currentClient.operations = $.client - .listServiceOperations(client) - .map((o) => prepareOperation(currentClient, o, { mutators: options?.operationMutators })); + currentClient.operations = $.client.listHttpOperations(client).map((o) => { + return { + client: currentClient, + httpOperation: o, + kind: "ClientOperation", + name: o.operation.name, + }; + }); + + $.client + .getConstructor(currentClient) + .parameters.properties.forEach((p) => collectDataTypes(p.type, dataTypes)); // Collect data types for (const clientOperation of currentClient.operations) { // Collect operation parameters - collectDataTypes(clientOperation.operation.parameters, dataTypes); + collectDataTypes(clientOperation.httpOperation.operation.parameters, dataTypes); // Collect http operation return type const responseType = $.httpOperation.getReturnType(clientOperation.httpOperation); @@ -146,30 +155,3 @@ function visitClient( return currentClient; } - -interface PrepareClientOperationOptions { - mutators?: unsafe_Mutator[]; -} - -function prepareOperation( - client: Client, - operation: Operation, - options: PrepareClientOperationOptions = {}, -): ClientOperation { - let op: Operation = operation; - - // We need to get the HttpOperation before running mutators to ensure that the httpOperation has full fidelity with the spec - const httpOperation = $.httpOperation.get(op); - - if (options.mutators) { - op = unsafe_mutateSubgraph($.program, options.mutators, operation).type as Operation; - } - - return { - kind: "ClientOperation", - client, - httpOperation, - name: op.name, - operation: op, - }; -} diff --git a/packages/http-client/src/interfaces.ts b/packages/http-client/src/interfaces.ts index 81993f934e1..74d63453a44 100644 --- a/packages/http-client/src/interfaces.ts +++ b/packages/http-client/src/interfaces.ts @@ -1,4 +1,4 @@ -import { Interface, Namespace, Operation } from "@typespec/compiler"; +import { Interface, Namespace } from "@typespec/compiler"; import { HttpOperation } from "@typespec/http"; export interface InternalClient { @@ -17,7 +17,6 @@ export interface Client extends InternalClient { export interface ClientOperation { kind: "ClientOperation"; name: string; - operation: Operation; httpOperation: HttpOperation; client: Client; } diff --git a/packages/http-client/src/typekit/kits/client-library.ts b/packages/http-client/src/typekit/kits/client-library.ts index 9afb6d5b23f..d81e25b889d 100644 --- a/packages/http-client/src/typekit/kits/client-library.ts +++ b/packages/http-client/src/typekit/kits/client-library.ts @@ -6,13 +6,12 @@ import { Model, Namespace, navigateType, - Operation, Scalar, Type, Union, } from "@typespec/compiler"; import { $, defineKit } from "@typespec/compiler/experimental/typekit"; -import { isHttpFile } from "@typespec/http"; +import { HttpOperation, isHttpFile } from "@typespec/http"; import { InternalClient } from "../../interfaces.js"; /** @@ -140,9 +139,9 @@ export interface TypeCollectorOptions { export function collectTypes(client: InternalClient, options: TypeCollectorOptions = {}) { const dataTypes = new Set(); - const operations: Operation[] = []; + const operations: HttpOperation[] = []; $.client.flat(client).forEach((c) => { - const ops = $.client.listServiceOperations(c); + const ops = $.client.listHttpOperations(c); operations.push(...ops); const params = $.client.getConstructor(c).parameters; @@ -150,7 +149,7 @@ export function collectTypes(client: InternalClient, options: TypeCollectorOptio }); for (const operation of operations) { - collectDataType(operation, dataTypes, options); + collectDataType(operation.operation, dataTypes, options); } return { diff --git a/packages/http-client/src/typekit/kits/client.ts b/packages/http-client/src/typekit/kits/client.ts index a482f266e3d..71f701f3e51 100644 --- a/packages/http-client/src/typekit/kits/client.ts +++ b/packages/http-client/src/typekit/kits/client.ts @@ -2,14 +2,17 @@ import { Interface, isTemplateDeclaration, isTemplateDeclarationOrInstance, + ModelProperty, Namespace, NoTarget, Operation, } from "@typespec/compiler"; -import { defineKit } from "@typespec/compiler/experimental/typekit"; +import { $, defineKit } from "@typespec/compiler/experimental/typekit"; import { getHttpService, getServers, + HttpOperation, + HttpServer, HttpServiceAuthentication, resolveAuthentication, } from "@typespec/http"; @@ -17,6 +20,7 @@ import "@typespec/http/experimental/typekit"; import { InternalClient } from "../../interfaces.js"; import { reportDiagnostic } from "../../lib.js"; import { createBaseConstructor, getConstructors } from "../../utils/client-helpers.js"; +import { getStringValue } from "../../utils/helpers.js"; import { NameKit } from "./utils.js"; interface ClientKit extends NameKit { @@ -53,11 +57,14 @@ interface ClientKit extends NameKit { * * @param client the client to get the methods for */ - listServiceOperations(client: InternalClient): Operation[]; + listHttpOperations(client: InternalClient): HttpOperation[]; /** * Get the url template of a client, given its constructor as well */ - getUrlTemplate(client: InternalClient, constructor: Operation): string; + getUrlTemplate( + client: InternalClient, + constructor?: Operation, + ): { url: string; parameters: ModelProperty[] }; /** * Determines is both clients have the same constructor */ @@ -67,6 +74,11 @@ interface ClientKit extends NameKit { * @param client */ getAuth(client: InternalClient): HttpServiceAuthentication; + /** + * Lists servers for a client or its closest parent's sever + * @param client client to check for servers + */ + listServers(client: InternalClient): HttpServer[] | undefined; } interface TypekitExtension { @@ -82,7 +94,7 @@ function getClientName(name: string): string { } export const clientCache = new Map(); -export const clientOperationCache = new Map(); +export const clientOperationCache = new Map(); defineKit({ client: { @@ -140,7 +152,7 @@ defineKit({ isPubliclyInitializable(client) { return client.type.kind === "Namespace"; }, - listServiceOperations(client) { + listHttpOperations(client) { if (clientOperationCache.has(client)) { return clientOperationCache.get(client)!; } @@ -161,32 +173,71 @@ defineKit({ operations.push(clientOperation); } - clientOperationCache.set(client, operations); + const httpOperations = operations.map((o) => this.httpOperation.get(o)); + clientOperationCache.set(client, httpOperations); - return operations; + return httpOperations; }, - getUrlTemplate(client, constructor) { - const params = this.operation.getClientSignature(client, constructor); - const endpointParams = params - .filter( - (p) => - this.modelProperty.getName(p) === "endpoint" || this.modelProperty.isHttpPathParam(p), - ) - .map((p) => p.name) - .sort(); - if (endpointParams.length === 1) { - return "{endpoint}"; + getUrlTemplate(client, _constructor) { + const constructor = _constructor ? _constructor : this.client.getConstructor(client); + // By default, we assume that the client has a single templated argument with a single endpoint parameter + const endpointTemplate: { url: string; parameters: ModelProperty[] } = { + url: "{endpoint}", + parameters: [ + this.modelProperty.create({ name: "endpoint", type: $.builtin.string, optional: false }), + ], + }; + + const servers = this.client.listServers(client); + + // There are no servers defined for this client + // Give the client a required endpoint parameter + if (!servers || servers.length === 0) { + return endpointTemplate; } - // here we have multiple templated arguments to an endpoint - const servers = getServers(this.program, client.service) || []; + for (const server of servers) { const serverParams = [...server.parameters.values()].map((p) => p.name).sort(); - if (JSON.stringify(serverParams) === JSON.stringify(endpointParams)) { - // this is the server we want - return server.url; + + let constructorParams = constructor + ? [...constructor.parameters.properties.values()].map((p) => p.name).sort() + : []; + const optionalConstructorParams = constructor?.parameters.properties.get("options"); + if (optionalConstructorParams && this.model.is(optionalConstructorParams.type)) { + constructorParams = [ + ...constructorParams, + ...Array.from(optionalConstructorParams.type.properties.values()).map((p) => p.name), + ]; + } + + // If we don't have any parameters in the server and the constructor says endpoint + if ( + !serverParams.length && + constructorParams.length === 1 && + constructorParams[0] === "endpoint" + ) { + return { + url: "{endpoint}", + parameters: [ + // Add the endpoint parameter as optional since we have a default url + this.modelProperty.create({ + name: "endpoint", + type: $.builtin.string, + optional: true, + defaultValue: getStringValue(server.url), + }), + ], + }; } + + // TODO: Handle multiple servers + + return { url: server.url, parameters: Array.from(server.parameters.values()) }; } - return "{endpoint}"; + + // Couldn't find a match, return the default + throw new Error("Couldn't find a matching server for the client"); + return endpointTemplate; }, haveSameConstructor(a, b) { const aConstructor = this.client.getConstructor(a); @@ -211,5 +262,24 @@ defineKit({ const [httpService] = getHttpService(this.program, client.service); return resolveAuthentication(httpService); }, + listServers(client) { + const currentServers = getServers(this.program, client.service); + + if (currentServers) { + return currentServers; + } + + let parent = this.client.getParent(client); + + while (parent) { + const servers = getServers(this.program, parent.service); + if (servers) { + return servers; + } + parent = this.client.getParent(parent); + } + + return undefined; + }, }, }); diff --git a/packages/http-client/src/typekit/kits/model.ts b/packages/http-client/src/typekit/kits/model.ts index 148288b36dd..0d2b8330317 100644 --- a/packages/http-client/src/typekit/kits/model.ts +++ b/packages/http-client/src/typekit/kits/model.ts @@ -4,7 +4,6 @@ import { ignoreDiagnostics, Model, ModelProperty, - Type, } from "@typespec/compiler"; import { defineKit } from "@typespec/compiler/experimental/typekit"; import { AccessKit, getAccess, getName, getUsage, NameKit, UsageKit } from "./utils.js"; @@ -17,13 +16,6 @@ export interface SdkModelKit extends NameKit, AccessKit, UsageKit< */ listProperties(model: Model): ModelProperty[]; - /** - * Get type of additionalProperties, if there are additional properties - * - * @param model model to get the additional properties type of - */ - getAdditionalPropertiesType(model: Model): Type | undefined; - /** * Get discriminator of a model, if a discriminator exists * @@ -66,17 +58,6 @@ defineKit({ listProperties(model) { return [...model.properties.values()]; }, - getAdditionalPropertiesType(model) { - // model MyModel is Record<> {} should be model with additional properties - if (model.sourceModel?.kind === "Model" && model.sourceModel?.name === "Record") { - return model.sourceModel!.indexer!.value!; - } - // model MyModel { ...Record<>} should be model with additional properties - if (model.indexer) { - return model.indexer.value; - } - return undefined; - }, getDiscriminatorProperty(model) { const discriminator = getDiscriminator(this.program, model); if (!discriminator) return undefined; diff --git a/packages/http-client/src/typekit/kits/operation.ts b/packages/http-client/src/typekit/kits/operation.ts index ca083526d01..5e35c907d43 100644 --- a/packages/http-client/src/typekit/kits/operation.ts +++ b/packages/http-client/src/typekit/kits/operation.ts @@ -1,5 +1,6 @@ import { ModelProperty, Operation, Type } from "@typespec/compiler"; import { defineKit } from "@typespec/compiler/experimental/typekit"; +import { HttpOperation } from "@typespec/http"; import { InternalClient as Client } from "../../interfaces.js"; import { getConstructors } from "../../utils/client-helpers.js"; import { clientOperationCache } from "./client.js"; @@ -31,7 +32,7 @@ export interface SdkOperationKit extends NameKit, AccessKit { const namespace = $.program.getGlobalNamespaceType(); const client = $.client.getClient(namespace); - const operations = $.client.listServiceOperations(client); + const operations = $.client.listHttpOperations(client); expect(operations).toHaveLength(1); - expect(operations[0].name).toEqual("foo"); + expect(operations[0].operation.name).toEqual("foo"); }); it("no operations", async () => { @@ -417,7 +417,7 @@ describe("listServiceOperations", () => { @test namespace DemoService; `)) as { DemoService: Namespace }; const client = $.clientLibrary.listClients(DemoService)[0]; - const operations = $.client.listServiceOperations(client); + const operations = $.client.listHttpOperations(client); expect(operations).toHaveLength(0); }); it("nested namespace", async () => { @@ -436,11 +436,15 @@ describe("listServiceOperations", () => { `)) as { DemoService: Namespace; NestedService: Namespace }; const demoServiceClient = $.clientLibrary.listClients(DemoService)[0]; - expect($.client.listServiceOperations(demoServiceClient)).toHaveLength(1); - expect($.client.listServiceOperations(demoServiceClient)[0].name).toEqual("demoServiceOp"); + expect($.client.listHttpOperations(demoServiceClient)).toHaveLength(1); + expect($.client.listHttpOperations(demoServiceClient)[0].operation.name).toEqual( + "demoServiceOp", + ); const nestedServiceClient = $.clientLibrary.listClients(NestedService)[0]; - expect($.client.listServiceOperations(nestedServiceClient)).toHaveLength(1); - expect($.client.listServiceOperations(nestedServiceClient)[0].name).toEqual("nestedServiceOp"); + expect($.client.listHttpOperations(nestedServiceClient)).toHaveLength(1); + expect($.client.listHttpOperations(nestedServiceClient)[0].operation.name).toEqual( + "nestedServiceOp", + ); }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 77eb70818da..481373c55b9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -690,6 +690,9 @@ importers: '@types/node': specifier: ~22.10.10 version: 22.10.10 + concurrently: + specifier: ^9.1.2 + version: 9.1.2 eslint: specifier: ^9.18.0 version: 9.18.0(jiti@1.21.6) @@ -703,6 +706,106 @@ importers: specifier: ^3.0.5 version: 3.0.5(@types/debug@4.1.12)(@types/node@22.10.10)(@vitest/ui@3.0.4)(happy-dom@16.7.2)(jiti@1.21.6)(jsdom@19.0.0)(tsx@4.19.2)(yaml@2.7.0) + packages/http-client-js: + dependencies: + '@alloy-js/core': + specifier: ^0.5.0 + version: 0.5.0 + '@alloy-js/typescript': + specifier: ^0.5.0 + version: 0.5.0 + '@typespec/compiler': + specifier: workspace:~ + version: link:../compiler + '@typespec/emitter-framework': + specifier: workspace:~ + version: link:../emitter-framework + '@typespec/http': + specifier: workspace:~ + version: link:../http + '@typespec/http-client': + specifier: workspace:~ + version: link:../http-client + '@typespec/rest': + specifier: workspace:~ + version: link:../rest + prettier: + specifier: ~3.4.2 + version: 3.4.2 + devDependencies: + '@alloy-js/babel-preset': + specifier: ^0.1.1 + version: 0.1.1(@babel/core@7.26.0) + '@babel/cli': + specifier: ^7.24.8 + version: 7.26.4(@babel/core@7.26.0) + '@babel/core': + specifier: ^7.26.0 + version: 7.26.0 + '@rollup/plugin-babel': + specifier: ^6.0.4 + version: 6.0.4(@babel/core@7.26.0)(@types/babel__core@7.20.5)(rollup@4.34.6) + '@types/yargs': + specifier: ~17.0.33 + version: 17.0.33 + '@typespec/http-specs': + specifier: 0.1.0-alpha.9 + version: 0.1.0-alpha.9(@typespec/compiler@packages+compiler)(@typespec/http@packages+http)(@typespec/rest@packages+rest)(@typespec/streams@0.65.0(@typespec/compiler@packages+compiler))(@typespec/versioning@packages+versioning)(@typespec/xml@0.65.0(@typespec/compiler@packages+compiler)) + '@typespec/spector': + specifier: workspace:~ + version: link:../spector + '@typespec/ts-http-runtime': + specifier: 0.1.0 + version: 0.1.0 + '@typespec/versioning': + specifier: workspace:~ + version: link:../versioning + '@vitest/ui': + specifier: ^3.0.3 + version: 3.0.4(vitest@3.0.5) + chalk: + specifier: ^2.4.2 + version: 2.4.2 + change-case: + specifier: ~5.4.4 + version: 5.4.4 + concurrently: + specifier: ^9.1.2 + version: 9.1.2 + dotenv: + specifier: ^16.4.7 + version: 16.4.7 + execa: + specifier: ^9.5.2 + version: 9.5.2 + fs-extra: + specifier: ^11.2.0 + version: 11.2.0 + globby: + specifier: ~14.0.2 + version: 14.0.2 + inquirer: + specifier: ^12.2.0 + version: 12.4.2(@types/node@22.10.10) + ora: + specifier: ^8.1.1 + version: 8.2.0 + p-limit: + specifier: ^6.2.0 + version: 6.2.0 + typescript: + specifier: ~5.7.3 + version: 5.7.3 + uri-template: + specifier: ^2.0.0 + version: 2.0.0 + vitest: + specifier: ^3.0.5 + version: 3.0.5(@types/debug@4.1.12)(@types/node@22.10.10)(@vitest/ui@3.0.4)(happy-dom@16.7.2)(jiti@1.21.6)(jsdom@19.0.0)(tsx@4.19.2)(yaml@2.7.0) + yargs: + specifier: ~17.7.2 + version: 17.7.2 + packages/http-server-csharp: dependencies: change-case: @@ -6407,6 +6510,73 @@ packages: resolution: {integrity: sha512-BkLMNpdV6prozk8LlyK/SOoWLmUFi+ZD+pcqti9ILCbVvHGk1ui1g4jJOc2WDLaeExz2qWwojxlPce5PljcT3w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typespec/compiler@0.65.3': + resolution: {integrity: sha512-hPiTMyXYe1ips8o/1OcrzKkdrwFt3NinrD70AldhR6cPNz9O9y9r+TdE62c3VpPNknuamNVJn1jTkOWVhUczxA==} + engines: {node: '>=18.0.0'} + hasBin: true + + '@typespec/http-specs@0.1.0-alpha.9': + resolution: {integrity: sha512-IIzW606BcfVzAz9U6cWf4u4lGXlTy+UQI69mvW3qM5x2XfcMQJ5RRk/7vnjiendv2zVHBtW7ZRBMezqXkM6nNg==} + engines: {node: '>=16.0.0'} + peerDependencies: + '@typespec/compiler': ~0.65.0 + '@typespec/http': ~0.65.0 + '@typespec/rest': ~0.65.0 + '@typespec/versioning': ~0.65.0 + '@typespec/xml': ~0.65.0 + + '@typespec/http@0.65.0': + resolution: {integrity: sha512-mSJUVmlBq4VO2Pv+mXNXccsuq+8AySWUwbJbbyNQTtpb2M9MIKh2+fbnyb8EMjskxpBWNP2DqABhfEMVesVUkg==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@typespec/compiler': ~0.65.0 + '@typespec/streams': ~0.65.0 + peerDependenciesMeta: + '@typespec/streams': + optional: true + + '@typespec/rest@0.65.0': + resolution: {integrity: sha512-279qiO8wy5Ks2nZ/byoXhSey8Gk+leUFJVDMNkjxtxGIswN19EV/MwawUcFQF+gNvExM58RHTx+7BJuA22K6aQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@typespec/compiler': ~0.65.0 + '@typespec/http': ~0.65.0 + + '@typespec/spec-api@0.1.0-alpha.1': + resolution: {integrity: sha512-WjZ3D/Narn264gUIU4bZ1iSLsPeIMUjCX1k91+H93UNCp7TeX/RO5jDG08Ryp9TnZDRITMPcs4TRfO3p0551AA==} + engines: {node: '>=18.0.0'} + + '@typespec/spec-coverage-sdk@0.1.0-alpha.3': + resolution: {integrity: sha512-1n6xng80jPkS05S8vkkQ6eHtud4hIxe5VtxrqUjx5LfLPE4azV3CiVf8zbzzsR6c2DFsF7XtfcjVjcUgBHDaMA==} + engines: {node: '>=16.0.0'} + + '@typespec/spector@0.1.0-alpha.7': + resolution: {integrity: sha512-lYjozAlEubTnOxgua6SzHYmYAqfVUtyWbUwaM9tqSBKC70HAZBCTi9rzWAbJPgyRFAQ8gs/GQinFvusrJeTQQA==} + engines: {node: '>=16.0.0'} + hasBin: true + + '@typespec/streams@0.65.0': + resolution: {integrity: sha512-lfHlIweg/zwRyj4RoCMNbNUjx0Lj/FSg+HkjP7yjuk4C3n9t/kA/uqYeVQJm/kIvTGnU+MNFUyPh47Gt93x6+Q==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@typespec/compiler': ~0.65.0 + + '@typespec/ts-http-runtime@0.1.0': + resolution: {integrity: sha512-0NspintCRrSIIZBUtVfWjJ5TpOjpP0mNsJXZOqzuxdY/q2yCr0amyUCEw+WLhRykP39XMNMG0f1F9LbC2+c+Rw==} + engines: {node: '>=18.0.0'} + + '@typespec/versioning@0.65.0': + resolution: {integrity: sha512-ACNOSgWVpiBwLyA8UlDSDeby+xDYm6wnRCPmdtdoyUpEwgBV/DcJerYf/ujVSCF0jDHItLQ65pC3ydMJDsJWdQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@typespec/compiler': ~0.65.0 + + '@typespec/xml@0.65.0': + resolution: {integrity: sha512-tBK0gJNonuxLL9A/ob546UR2AtJuv0yzZfV1tn/afwB+P+BJKuYUGNTuP8k11uqo3BGlEk1vLOYuhy8JUS33sw==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@typespec/compiler': ~0.65.0 + '@ungap/structured-clone@1.2.0': resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==} @@ -9442,6 +9612,15 @@ packages: inline-style-parser@0.2.4: resolution: {integrity: sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==} + inquirer@12.4.2: + resolution: {integrity: sha512-reyjHcwyK2LObXgTJH4T1Dpfhwu88LNPTZmg/KenmTsy3T8lN/kZT8Oo7UwwkB9q8+ss2qjjN7GV8oFAfyz9Xg==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + internal-slot@1.0.7: resolution: {integrity: sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==} engines: {node: '>= 0.4'} @@ -11090,6 +11269,9 @@ packages: resolution: {integrity: sha512-iuh7L6jA7JEGu2WxDwtQP1ddOpaJNC4KlDEFfdQajSGgGPNi4OyDc2R7QnbY2bR9QjBVGwgvTdNJZoE7RaxUMA==} engines: {node: '>=0.12'} + pct-encode@1.0.3: + resolution: {integrity: sha512-+ojEvSHApoLWF2YYxwnOM4N9DPn5e5fG+j0YJ9drKNaYtrZYOq5M9ESOaBYqOHCXOAALODJJ4wkqHAXEuLpwMw==} + peek-stream@1.1.3: resolution: {integrity: sha512-FhJ+YbOSBb9/rIl2ZeE/QHEsWn7PqNYt8ARAY3kIgNGOk13g9FGyIY6JIl/xB/3TFRVoTv5as0l11weORrTekA==} @@ -11828,6 +12010,10 @@ packages: rtl-css-js@1.16.1: resolution: {integrity: sha512-lRQgou1mu19e+Ya0LsTvKrVJ5TYUbqCVPAiImX3UfLTenarvPUl1QFdvu5Z3PYmHT9RCcwIfbjRQBntExyj3Zg==} + run-async@3.0.0: + resolution: {integrity: sha512-540WwVDOMxA6dN6We19EcT9sc3hkXPw5mzRNGM3FkdN/vtE9NFvj5lFAPNwUDmJjXidm3v7TC1cTE7t17Ulm1Q==} + engines: {node: '>=0.12.0'} + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -12921,6 +13107,9 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + uri-template@2.0.0: + resolution: {integrity: sha512-r/i44nPoo0ktEZDjx+hxp9PSjQuBBfsd6RgCRuuMqCP0FZEp+YE0SpihThI4UGc5ePqQEFsdyZc7UVlowp+LLw==} + url-join@4.0.1: resolution: {integrity: sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==} @@ -18856,6 +19045,135 @@ snapshots: '@typescript-eslint/types': 8.21.0 eslint-visitor-keys: 4.2.0 + '@typespec/compiler@0.65.3': + dependencies: + '@babel/code-frame': 7.26.2 + '@npmcli/arborist': 8.0.0 + ajv: 8.17.1 + change-case: 5.4.4 + globby: 14.0.2 + mustache: 4.2.0 + picocolors: 1.1.1 + prettier: 3.4.2 + prompts: 2.4.2 + semver: 7.7.1 + temporal-polyfill: 0.2.5 + vscode-languageserver: 9.0.1 + vscode-languageserver-textdocument: 1.0.12 + yaml: 2.7.0 + yargs: 17.7.2 + transitivePeerDependencies: + - bluebird + - supports-color + + '@typespec/http-specs@0.1.0-alpha.9(@typespec/compiler@packages+compiler)(@typespec/http@packages+http)(@typespec/rest@packages+rest)(@typespec/streams@0.65.0(@typespec/compiler@packages+compiler))(@typespec/versioning@packages+versioning)(@typespec/xml@0.65.0(@typespec/compiler@packages+compiler))': + dependencies: + '@typespec/compiler': link:packages/compiler + '@typespec/http': link:packages/http + '@typespec/rest': link:packages/rest + '@typespec/spec-api': 0.1.0-alpha.1 + '@typespec/spector': 0.1.0-alpha.7(@typespec/streams@0.65.0(@typespec/compiler@packages+compiler)) + '@typespec/versioning': link:packages/versioning + '@typespec/xml': 0.65.0(@typespec/compiler@packages+compiler) + transitivePeerDependencies: + - '@types/express' + - '@typespec/streams' + - bluebird + - debug + - supports-color + + '@typespec/http@0.65.0(@typespec/compiler@0.65.3)(@typespec/streams@0.65.0(@typespec/compiler@packages+compiler))': + dependencies: + '@typespec/compiler': 0.65.3 + optionalDependencies: + '@typespec/streams': 0.65.0(@typespec/compiler@packages+compiler) + + '@typespec/rest@0.65.0(@typespec/compiler@0.65.3)(@typespec/http@0.65.0(@typespec/compiler@0.65.3)(@typespec/streams@0.65.0(@typespec/compiler@packages+compiler)))': + dependencies: + '@typespec/compiler': 0.65.3 + '@typespec/http': 0.65.0(@typespec/compiler@0.65.3)(@typespec/streams@0.65.0(@typespec/compiler@packages+compiler)) + + '@typespec/spec-api@0.1.0-alpha.1': + dependencies: + body-parser: 1.20.3 + deep-equal: 2.2.3 + express: 4.21.2 + express-promise-router: 4.1.1(@types/express@5.0.0)(express@4.21.2) + morgan: 1.10.0 + multer: 1.4.5-lts.1 + picocolors: 1.1.1 + prettier: 3.4.2 + winston: 3.17.0 + xml2js: 0.6.2 + yargs: 17.7.2 + transitivePeerDependencies: + - '@types/express' + - supports-color + + '@typespec/spec-coverage-sdk@0.1.0-alpha.3': + dependencies: + '@azure/identity': 4.6.0 + '@azure/storage-blob': 12.26.0 + '@types/node': 22.10.10 + transitivePeerDependencies: + - supports-color + + '@typespec/spector@0.1.0-alpha.7(@typespec/streams@0.65.0(@typespec/compiler@packages+compiler))': + dependencies: + '@azure/identity': 4.6.0 + '@types/js-yaml': 4.0.9 + '@typespec/compiler': 0.65.3 + '@typespec/http': 0.65.0(@typespec/compiler@0.65.3)(@typespec/streams@0.65.0(@typespec/compiler@packages+compiler)) + '@typespec/rest': 0.65.0(@typespec/compiler@0.65.3)(@typespec/http@0.65.0(@typespec/compiler@0.65.3)(@typespec/streams@0.65.0(@typespec/compiler@packages+compiler))) + '@typespec/spec-api': 0.1.0-alpha.1 + '@typespec/spec-coverage-sdk': 0.1.0-alpha.3 + '@typespec/versioning': 0.65.0(@typespec/compiler@0.65.3) + ajv: 8.17.1 + axios: 1.7.9 + body-parser: 1.20.3 + deep-equal: 2.2.3 + express: 4.21.2 + express-promise-router: 4.1.1(@types/express@5.0.0)(express@4.21.2) + form-data: 4.0.1 + globby: 14.0.2 + jackspeak: 4.0.2 + js-yaml: 4.1.0 + morgan: 1.10.0 + multer: 1.4.5-lts.1 + node-fetch: 3.3.2 + picocolors: 1.1.1 + source-map-support: 0.5.21 + winston: 3.17.0 + xml2js: 0.6.2 + yargs: 17.7.2 + transitivePeerDependencies: + - '@types/express' + - '@typespec/streams' + - bluebird + - debug + - supports-color + + '@typespec/streams@0.65.0(@typespec/compiler@packages+compiler)': + dependencies: + '@typespec/compiler': link:packages/compiler + optional: true + + '@typespec/ts-http-runtime@0.1.0': + dependencies: + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@typespec/versioning@0.65.0(@typespec/compiler@0.65.3)': + dependencies: + '@typespec/compiler': 0.65.3 + + '@typespec/xml@0.65.0(@typespec/compiler@packages+compiler)': + dependencies: + '@typespec/compiler': link:packages/compiler + '@ungap/structured-clone@1.2.0': {} '@vitejs/plugin-react@4.3.4(vite@6.0.11(@types/node@22.10.10)(jiti@1.21.6)(tsx@4.19.2)(yaml@2.7.0))': @@ -20141,7 +20459,7 @@ snapshots: dependencies: ansi-align: 3.0.1 camelcase: 8.0.0 - chalk: 5.3.0 + chalk: 5.4.1 cli-boxes: 3.0.0 string-width: 7.2.0 type-fest: 4.27.0 @@ -22943,6 +23261,18 @@ snapshots: inline-style-parser@0.2.4: {} + inquirer@12.4.2(@types/node@22.10.10): + dependencies: + '@inquirer/core': 10.1.7(@types/node@22.10.10) + '@inquirer/prompts': 7.3.2(@types/node@22.10.10) + '@inquirer/type': 3.0.4(@types/node@22.10.10) + ansi-escapes: 4.3.2 + mute-stream: 2.0.0 + run-async: 3.0.0 + rxjs: 7.8.1 + optionalDependencies: + '@types/node': 22.10.10 + internal-slot@1.0.7: dependencies: es-errors: 1.3.0 @@ -25056,6 +25386,8 @@ snapshots: safe-buffer: 5.2.1 sha.js: 2.4.11 + pct-encode@1.0.3: {} + peek-stream@1.1.3: dependencies: buffer-from: 1.1.2 @@ -25963,6 +26295,8 @@ snapshots: dependencies: '@babel/runtime': 7.26.0 + run-async@3.0.0: {} + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -27130,6 +27464,10 @@ snapshots: dependencies: punycode: 2.3.1 + uri-template@2.0.0: + dependencies: + pct-encode: 1.0.3 + url-join@4.0.1: {} url-parse@1.5.10: diff --git a/website/src/pages/can-i-use/http.astro b/website/src/pages/can-i-use/http.astro index 25cdbb58196..842e1ba0049 100644 --- a/website/src/pages/can-i-use/http.astro +++ b/website/src/pages/can-i-use/http.astro @@ -13,8 +13,7 @@ const options: CoverageFromAzureStorageOptions = { emitterNames: [ "@typespec/http-client-python", "@typespec/http-client-csharp", - "@azure-tools/typespec-ts-rlc", - "@azure-tools/typespec-ts-modular", + "@typespec/http-client-js", "@typespec/http-client-java", ], };