diff --git a/generators/typescript-v2/ast/src/custom-config/TypescriptCustomConfigSchema.ts b/generators/typescript-v2/ast/src/custom-config/TypescriptCustomConfigSchema.ts index 2d572c75324f..a4dd72172c0a 100644 --- a/generators/typescript-v2/ast/src/custom-config/TypescriptCustomConfigSchema.ts +++ b/generators/typescript-v2/ast/src/custom-config/TypescriptCustomConfigSchema.ts @@ -61,6 +61,11 @@ export const TypescriptCustomConfigSchema = z.strictObject({ // OAuth token override configuration oauthTokenOverride: z.optional(z.boolean()), + // Auth configuration for ANY auth requirement + // v1 (default): SDK tries each auth method and catches errors + // v2: User explicitly chooses the auth type via a `type` field (discriminated union) + anyAuth: z.optional(z.enum(["v1", "v2"])), + // beta (not in docs) includeContentHeadersOnFileDownloadResponse: z.optional(z.boolean()), includeUtilsOnUnionMembers: z.optional(z.boolean()), diff --git a/generators/typescript/sdk/cli/src/SdkGeneratorCli.ts b/generators/typescript/sdk/cli/src/SdkGeneratorCli.ts index c4fa77f3447b..aea25ca1ab9a 100644 --- a/generators/typescript/sdk/cli/src/SdkGeneratorCli.ts +++ b/generators/typescript/sdk/cli/src/SdkGeneratorCli.ts @@ -97,7 +97,8 @@ export class SdkGeneratorCli extends AbstractGeneratorCli { formatter: parsed?.formatter ?? "biome", generateSubpackageExports: parsed?.generateSubpackageExports ?? false, offsetSemantics: parsed?.offsetSemantics ?? "item-index", - oauthTokenOverride: parsed?.oauthTokenOverride ?? false + oauthTokenOverride: parsed?.oauthTokenOverride ?? false, + anyAuth: parsed?.anyAuth ?? "v1" }; if (parsed?.noSerdeLayer === false && typeof parsed?.enableInlineTypes === "undefined") { @@ -244,7 +245,8 @@ export class SdkGeneratorCli extends AbstractGeneratorCli { linter: customConfig.linter, generateSubpackageExports: customConfig.generateSubpackageExports ?? false, offsetSemantics: customConfig.offsetSemantics, - oauthTokenOverride: customConfig.oauthTokenOverride ?? false + oauthTokenOverride: customConfig.oauthTokenOverride ?? false, + anyAuth: customConfig.anyAuth ?? "v1" } }); const typescriptProject = await sdkGenerator.generate(); diff --git a/generators/typescript/sdk/cli/src/custom-config/SdkCustomConfig.ts b/generators/typescript/sdk/cli/src/custom-config/SdkCustomConfig.ts index 88726928a5e5..f9d880f13e0d 100644 --- a/generators/typescript/sdk/cli/src/custom-config/SdkCustomConfig.ts +++ b/generators/typescript/sdk/cli/src/custom-config/SdkCustomConfig.ts @@ -61,4 +61,5 @@ export interface SdkCustomConfig { generateSubpackageExports: boolean | undefined; offsetSemantics: "item-index" | "page-index"; oauthTokenOverride: boolean | undefined; + anyAuth: "v1" | "v2" | undefined; } diff --git a/generators/typescript/sdk/client-class-generator/src/AuthProvidersGenerator.ts b/generators/typescript/sdk/client-class-generator/src/AuthProvidersGenerator.ts index 7e60e00089e0..fcc3ec02dba7 100644 --- a/generators/typescript/sdk/client-class-generator/src/AuthProvidersGenerator.ts +++ b/generators/typescript/sdk/client-class-generator/src/AuthProvidersGenerator.ts @@ -5,6 +5,7 @@ import { GeneratedFile, SdkContext } from "@fern-typescript/contexts"; import { AnyAuthProviderGenerator, + AnyAuthV2ProviderGenerator, AuthProviderGenerator, BasicAuthProviderGenerator, BearerAuthProviderGenerator, @@ -16,7 +17,7 @@ import { export declare namespace AuthProvidersGenerator { export interface Init { ir: IntermediateRepresentation; - authScheme: AuthScheme | { type: "any" }; + authScheme: AuthScheme | { type: "any" } | { type: "anyAuthV2" }; neverThrowErrors: boolean; includeSerdeLayer: boolean; oauthTokenOverride: boolean; @@ -38,6 +39,13 @@ export class AuthProvidersGenerator implements GeneratedFile { return new AnyAuthProviderGenerator({ ir }); + case "anyAuthV2": + return new AnyAuthV2ProviderGenerator({ + ir, + neverThrowErrors, + includeSerdeLayer, + oauthTokenOverride + }); case "inferred": return new InferredAuthProviderGenerator({ ir, diff --git a/generators/typescript/sdk/client-class-generator/src/BaseClientTypeGenerator.ts b/generators/typescript/sdk/client-class-generator/src/BaseClientTypeGenerator.ts index c4508121c173..517b7a42fbe4 100644 --- a/generators/typescript/sdk/client-class-generator/src/BaseClientTypeGenerator.ts +++ b/generators/typescript/sdk/client-class-generator/src/BaseClientTypeGenerator.ts @@ -11,6 +11,7 @@ export declare namespace BaseClientTypeGenerator { ir: FernIr.IntermediateRepresentation; omitFernHeaders: boolean; oauthTokenOverride: boolean; + anyAuth: "v1" | "v2"; } } @@ -22,17 +23,20 @@ export class BaseClientTypeGenerator { private readonly ir: FernIr.IntermediateRepresentation; private readonly omitFernHeaders: boolean; private readonly oauthTokenOverride: boolean; + private readonly anyAuth: "v1" | "v2"; constructor({ generateIdempotentRequestOptions, ir, omitFernHeaders, - oauthTokenOverride + oauthTokenOverride, + anyAuth }: BaseClientTypeGenerator.Init) { this.generateIdempotentRequestOptions = generateIdempotentRequestOptions; this.ir = ir; this.omitFernHeaders = omitFernHeaders; this.oauthTokenOverride = oauthTokenOverride; + this.anyAuth = anyAuth; } public writeToFile(context: SdkContext): void { @@ -85,7 +89,11 @@ export type BaseClientOptions = { const isAnyAuth = this.ir.auth.requirement === "ANY"; if (isAnyAuth) { - authOptionsTypes.push("AnyAuthProvider.AuthOptions"); + if (this.anyAuth === "v2") { + authOptionsTypes.push("AnyAuthProvider.AuthOptions"); + } else { + authOptionsTypes.push("AnyAuthProvider.AuthOptions"); + } } else { for (const authScheme of this.ir.auth.schemes) { const authOptionsType = this.getAuthOptionsTypeForScheme(authScheme, context); @@ -239,60 +247,71 @@ export type NormalizedClientOptionsWithAuth = Norma const isAnyAuth = this.ir.auth.requirement === "ANY"; if (isAnyAuth) { - context.sourceFile.addImportDeclaration({ - moduleSpecifier: "./auth/AnyAuthProvider.js", - namedImports: ["AnyAuthProvider"] - }); - - const providerImports: string[] = []; - const providerInstantiations: string[] = []; - - for (const authScheme of this.ir.auth.schemes) { - if (authScheme.type === "bearer") { - context.sourceFile.addImportDeclaration({ - moduleSpecifier: "./auth/BearerAuthProvider.js", - namedImports: ["BearerAuthProvider"] - }); - providerImports.push("BearerAuthProvider"); - providerInstantiations.push( - "if (BearerAuthProvider.canCreate(normalizedWithNoOpAuthProvider)) { authProviders.push(new BearerAuthProvider(normalizedWithNoOpAuthProvider)); }" - ); - } else if (authScheme.type === "basic") { - context.sourceFile.addImportDeclaration({ - moduleSpecifier: "./auth/BasicAuthProvider.js", - namedImports: ["BasicAuthProvider"] - }); - providerImports.push("BasicAuthProvider"); - providerInstantiations.push( - "if (BasicAuthProvider.canCreate(normalizedWithNoOpAuthProvider)) { authProviders.push(new BasicAuthProvider(normalizedWithNoOpAuthProvider)); }" - ); - } else if (authScheme.type === "header") { - context.sourceFile.addImportDeclaration({ - moduleSpecifier: "./auth/HeaderAuthProvider.js", - namedImports: ["HeaderAuthProvider"] - }); - providerImports.push("HeaderAuthProvider"); - providerInstantiations.push( - "if (HeaderAuthProvider.canCreate(normalizedWithNoOpAuthProvider)) { authProviders.push(new HeaderAuthProvider(normalizedWithNoOpAuthProvider)); }" - ); - } else if (authScheme.type === "oauth") { - context.sourceFile.addImportDeclaration({ - moduleSpecifier: "./auth/OAuthAuthProvider.js", - namedImports: ["OAuthAuthProvider"] - }); - providerImports.push("OAuthAuthProvider"); - const oauthCreation = this.oauthTokenOverride - ? "if (OAuthAuthProvider.canCreate(normalizedWithNoOpAuthProvider)) { authProviders.push(OAuthAuthProvider.createInstance(normalizedWithNoOpAuthProvider)); }" - : "if (OAuthAuthProvider.canCreate(normalizedWithNoOpAuthProvider)) { authProviders.push(new OAuthAuthProvider(normalizedWithNoOpAuthProvider)); }"; - providerInstantiations.push(oauthCreation); + if (this.anyAuth === "v2") { + // Use AnyAuthProvider v2 (discriminated union style) + context.sourceFile.addImportDeclaration({ + moduleSpecifier: "./auth/AnyAuthProvider.js", + namedImports: ["AnyAuthProvider"] + }); + + authProviderCreation = `new AnyAuthProvider(${OPTIONS_PARAMETER_NAME})`; + } else { + // Use the existing AnyAuthProvider approach + context.sourceFile.addImportDeclaration({ + moduleSpecifier: "./auth/AnyAuthProvider.js", + namedImports: ["AnyAuthProvider"] + }); + + const providerImports: string[] = []; + const providerInstantiations: string[] = []; + + for (const authScheme of this.ir.auth.schemes) { + if (authScheme.type === "bearer") { + context.sourceFile.addImportDeclaration({ + moduleSpecifier: "./auth/BearerAuthProvider.js", + namedImports: ["BearerAuthProvider"] + }); + providerImports.push("BearerAuthProvider"); + providerInstantiations.push( + "if (BearerAuthProvider.canCreate(normalizedWithNoOpAuthProvider)) { authProviders.push(new BearerAuthProvider(normalizedWithNoOpAuthProvider)); }" + ); + } else if (authScheme.type === "basic") { + context.sourceFile.addImportDeclaration({ + moduleSpecifier: "./auth/BasicAuthProvider.js", + namedImports: ["BasicAuthProvider"] + }); + providerImports.push("BasicAuthProvider"); + providerInstantiations.push( + "if (BasicAuthProvider.canCreate(normalizedWithNoOpAuthProvider)) { authProviders.push(new BasicAuthProvider(normalizedWithNoOpAuthProvider)); }" + ); + } else if (authScheme.type === "header") { + context.sourceFile.addImportDeclaration({ + moduleSpecifier: "./auth/HeaderAuthProvider.js", + namedImports: ["HeaderAuthProvider"] + }); + providerImports.push("HeaderAuthProvider"); + providerInstantiations.push( + "if (HeaderAuthProvider.canCreate(normalizedWithNoOpAuthProvider)) { authProviders.push(new HeaderAuthProvider(normalizedWithNoOpAuthProvider)); }" + ); + } else if (authScheme.type === "oauth") { + context.sourceFile.addImportDeclaration({ + moduleSpecifier: "./auth/OAuthAuthProvider.js", + namedImports: ["OAuthAuthProvider"] + }); + providerImports.push("OAuthAuthProvider"); + const oauthCreation = this.oauthTokenOverride + ? "if (OAuthAuthProvider.canCreate(normalizedWithNoOpAuthProvider)) { authProviders.push(OAuthAuthProvider.createInstance(normalizedWithNoOpAuthProvider)); }" + : "if (OAuthAuthProvider.canCreate(normalizedWithNoOpAuthProvider)) { authProviders.push(new OAuthAuthProvider(normalizedWithNoOpAuthProvider)); }"; + providerInstantiations.push(oauthCreation); + } } - } - authProviderCreation = `(() => { + authProviderCreation = `(() => { const authProviders: ${getTextOfTsNode(context.coreUtilities.auth.AuthProvider._getReferenceToType())}[] = []; ${providerInstantiations.join("\n ")} return new AnyAuthProvider(authProviders); })()`; + } } else { for (const authScheme of this.ir.auth.schemes) { if (authScheme.type === "bearer") { diff --git a/generators/typescript/sdk/client-class-generator/src/GeneratedSdkClientClassImpl.ts b/generators/typescript/sdk/client-class-generator/src/GeneratedSdkClientClassImpl.ts index 3cfd68739536..2dab074ecb5b 100644 --- a/generators/typescript/sdk/client-class-generator/src/GeneratedSdkClientClassImpl.ts +++ b/generators/typescript/sdk/client-class-generator/src/GeneratedSdkClientClassImpl.ts @@ -45,6 +45,7 @@ import { import { Code, code } from "ts-poet"; import { AnyAuthProviderInstance, + AnyAuthV2ProviderInstance, AuthProviderInstance, BasicAuthProviderInstance, BearerAuthProviderInstance, @@ -94,6 +95,7 @@ export declare namespace GeneratedSdkClientClassImpl { parameterNaming: "originalName" | "wireValue" | "camelCase" | "snakeCase" | "default"; offsetSemantics: "item-index" | "page-index"; oauthTokenOverride: boolean; + anyAuth: "v1" | "v2"; } } @@ -165,7 +167,8 @@ export class GeneratedSdkClientClassImpl implements GeneratedSdkClientClass { generateEndpointMetadata, parameterNaming, offsetSemantics, - oauthTokenOverride + oauthTokenOverride, + anyAuth }: GeneratedSdkClientClassImpl.Init) { this.isRoot = isRoot; this.intermediateRepresentation = intermediateRepresentation; @@ -436,19 +439,24 @@ export class GeneratedSdkClientClassImpl implements GeneratedSdkClientClass { } }); - for (const authScheme of authSchemes) { - if (isAnyAuth) { - const authProvider = getAuthProvider(authScheme); - anyAuthProviders.push(authProvider); - } else { - this.authProvider = getAuthProvider(authScheme); - break; + // If anyAuth is v2, use the AnyAuthV2 provider (discriminated union style) + if (isAnyAuth && anyAuth === "v2") { + this.authProvider = new AnyAuthV2ProviderInstance(intermediateRepresentation); + } else { + for (const authScheme of authSchemes) { + if (isAnyAuth) { + const authProvider = getAuthProvider(authScheme); + anyAuthProviders.push(authProvider); + } else { + this.authProvider = getAuthProvider(authScheme); + break; + } } - } - // After the loop, if isAnyAuth, create AnyAuthProviderInstance with all collected providers - if (isAnyAuth && anyAuthProviders.length > 0) { - this.authProvider = new AnyAuthProviderInstance(anyAuthProviders); + // After the loop, if isAnyAuth, create AnyAuthProviderInstance with all collected providers + if (isAnyAuth && anyAuthProviders.length > 0) { + this.authProvider = new AnyAuthProviderInstance(anyAuthProviders); + } } } diff --git a/generators/typescript/sdk/client-class-generator/src/SdkClientClassGenerator.ts b/generators/typescript/sdk/client-class-generator/src/SdkClientClassGenerator.ts index 8df6c9c6c495..d3ff7ef2f077 100644 --- a/generators/typescript/sdk/client-class-generator/src/SdkClientClassGenerator.ts +++ b/generators/typescript/sdk/client-class-generator/src/SdkClientClassGenerator.ts @@ -32,6 +32,7 @@ export declare namespace SdkClientClassGenerator { parameterNaming: "originalName" | "wireValue" | "camelCase" | "snakeCase" | "default"; offsetSemantics: "item-index" | "page-index"; oauthTokenOverride: boolean; + anyAuth: "v1" | "v2"; } export namespace generateService { @@ -70,6 +71,7 @@ export class SdkClientClassGenerator { private readonly parameterNaming: "originalName" | "wireValue" | "camelCase" | "snakeCase" | "default"; private readonly offsetSemantics: "item-index" | "page-index"; private readonly oauthTokenOverride: boolean; + private readonly anyAuth: "v1" | "v2"; constructor({ intermediateRepresentation, @@ -96,7 +98,8 @@ export class SdkClientClassGenerator { generateEndpointMetadata, parameterNaming, offsetSemantics, - oauthTokenOverride + oauthTokenOverride, + anyAuth }: SdkClientClassGenerator.Init) { this.intermediateRepresentation = intermediateRepresentation; this.errorResolver = errorResolver; @@ -123,6 +126,7 @@ export class SdkClientClassGenerator { this.parameterNaming = parameterNaming; this.offsetSemantics = offsetSemantics; this.oauthTokenOverride = oauthTokenOverride; + this.anyAuth = anyAuth; } public generateService({ @@ -159,7 +163,8 @@ export class SdkClientClassGenerator { generateEndpointMetadata: this.generateEndpointMetadata, parameterNaming: this.parameterNaming, offsetSemantics: this.offsetSemantics, - oauthTokenOverride: this.oauthTokenOverride + oauthTokenOverride: this.oauthTokenOverride, + anyAuth: this.anyAuth }); } } diff --git a/generators/typescript/sdk/client-class-generator/src/auth-provider/AnyAuthV2ProviderGenerator.ts b/generators/typescript/sdk/client-class-generator/src/auth-provider/AnyAuthV2ProviderGenerator.ts new file mode 100644 index 000000000000..a08fa695dafa --- /dev/null +++ b/generators/typescript/sdk/client-class-generator/src/auth-provider/AnyAuthV2ProviderGenerator.ts @@ -0,0 +1,319 @@ +import { FernIr } from "@fern-fern/ir-sdk"; +import { ExportedFilePath, getTextOfTsNode } from "@fern-typescript/commons"; +import { SdkContext } from "@fern-typescript/contexts"; +import { OptionalKind, PropertySignatureStructure, Scope, StructureKind, ts } from "ts-morph"; +import { AuthProviderGenerator } from "./AuthProviderGenerator"; +import { BasicAuthProviderGenerator } from "./BasicAuthProviderGenerator"; +import { BearerAuthProviderGenerator } from "./BearerAuthProviderGenerator"; +import { HeaderAuthProviderGenerator } from "./HeaderAuthProviderGenerator"; +import { OAuthAuthProviderGenerator } from "./OAuthAuthProviderGenerator"; + +export declare namespace AnyAuthV2ProviderGenerator { + export interface Init { + ir: FernIr.IntermediateRepresentation; + neverThrowErrors: boolean; + includeSerdeLayer: boolean; + oauthTokenOverride: boolean; + } +} + +const CLASS_NAME = "AnyAuthProvider"; +const AUTH_OPTIONS_TYPE_NAME = "AuthOptions"; +const AUTH_FIELD_NAME = "auth"; +const OPTIONS_FIELD_NAME = "options"; +const DELEGATE_FIELD_NAME = "delegate"; + +export class AnyAuthV2ProviderGenerator implements AuthProviderGenerator { + public static readonly CLASS_NAME = CLASS_NAME; + public static readonly AUTH_FIELD_NAME = AUTH_FIELD_NAME; + private readonly ir: FernIr.IntermediateRepresentation; + private readonly neverThrowErrors: boolean; + private readonly includeSerdeLayer: boolean; + private readonly oauthTokenOverride: boolean; + + constructor(init: AnyAuthV2ProviderGenerator.Init) { + this.ir = init.ir; + this.neverThrowErrors = init.neverThrowErrors; + this.includeSerdeLayer = init.includeSerdeLayer; + this.oauthTokenOverride = init.oauthTokenOverride; + } + + public getFilePath(): ExportedFilePath { + return { + directories: [{ nameOnDisk: "auth" }], + file: { + nameOnDisk: `${CLASS_NAME}.ts`, + exportDeclaration: { namedExports: [CLASS_NAME] } + } + }; + } + + public getAuthProviderClassType(): ts.TypeNode { + return ts.factory.createTypeReferenceNode(CLASS_NAME); + } + + public getOptionsType(): ts.TypeNode { + throw new Error("AnyAuthProvider does not have an Options type"); + } + + public getAuthOptionsType(): ts.TypeNode { + return ts.factory.createTypeReferenceNode(`${CLASS_NAME}.${AUTH_OPTIONS_TYPE_NAME}`); + } + + public getAuthOptionsProperties(_context: SdkContext): OptionalKind[] | undefined { + return undefined; + } + + public instantiate(constructorArgs: ts.Expression[]): ts.Expression { + return ts.factory.createNewExpression(ts.factory.createIdentifier(CLASS_NAME), undefined, constructorArgs); + } + + public writeToFile(context: SdkContext): void { + this.addImports(context); + this.writeAuthOptionsType(context); + this.writeClass(context); + } + + private addImports(context: SdkContext): void { + context.sourceFile.addImportDeclaration({ + moduleSpecifier: "../core/index.js", + namespaceImport: "core" + }); + + // Import BaseClientOptions for the constructor parameter type + context.sourceFile.addImportDeclaration({ + moduleSpecifier: "../BaseClient.js", + namedImports: ["BaseClientOptions"], + isTypeOnly: true + }); + + // Import individual auth providers based on auth schemes + const authSchemes = this.ir.auth.schemes; + for (const authScheme of authSchemes) { + switch (authScheme.type) { + case "bearer": + context.sourceFile.addImportDeclaration({ + moduleSpecifier: `./${BearerAuthProviderGenerator.CLASS_NAME}.js`, + namedImports: [BearerAuthProviderGenerator.CLASS_NAME] + }); + break; + case "basic": + context.sourceFile.addImportDeclaration({ + moduleSpecifier: `./${BasicAuthProviderGenerator.CLASS_NAME}.js`, + namedImports: [BasicAuthProviderGenerator.CLASS_NAME] + }); + break; + case "header": + context.sourceFile.addImportDeclaration({ + moduleSpecifier: `./${HeaderAuthProviderGenerator.CLASS_NAME}.js`, + namedImports: [HeaderAuthProviderGenerator.CLASS_NAME] + }); + break; + case "oauth": + if (context.generateOAuthClients) { + context.sourceFile.addImportDeclaration({ + moduleSpecifier: `./${OAuthAuthProviderGenerator.CLASS_NAME}.js`, + namedImports: [OAuthAuthProviderGenerator.CLASS_NAME] + }); + } + break; + case "inferred": + break; + } + } + } + + private getAuthSchemeKey(authScheme: FernIr.AuthScheme): string { + switch (authScheme.type) { + case "bearer": + return authScheme.key; + case "basic": + return authScheme.key; + case "header": + return authScheme.key; + case "oauth": + return authScheme.key; + case "inferred": + return authScheme.key; + default: + throw new Error(`Unknown auth scheme type: ${(authScheme as FernIr.AuthScheme).type}`); + } + } + + private writeAuthOptionsType(context: SdkContext): void { + const authSchemes = this.ir.auth.schemes; + const unionMembers: string[] = []; + + for (const authScheme of authSchemes) { + const schemeKey = this.getAuthSchemeKey(authScheme); + + switch (authScheme.type) { + case "bearer": { + // Use the BearerAuthProvider.AuthOptions type + unionMembers.push( + `{ type: "${schemeKey}" } & ${BearerAuthProviderGenerator.CLASS_NAME}.AuthOptions` + ); + break; + } + case "basic": { + // Use the BasicAuthProvider.AuthOptions type + unionMembers.push( + `{ type: "${schemeKey}" } & ${BasicAuthProviderGenerator.CLASS_NAME}.AuthOptions` + ); + break; + } + case "header": { + // Use the HeaderAuthProvider.AuthOptions type + unionMembers.push( + `{ type: "${schemeKey}" } & ${HeaderAuthProviderGenerator.CLASS_NAME}.AuthOptions` + ); + break; + } + case "oauth": { + const config = authScheme.configuration; + if (config.type === "clientCredentials" && context.generateOAuthClients) { + // Use the OAuthAuthProvider.AuthOptions type + unionMembers.push( + `{ type: "${schemeKey}" } & ${OAuthAuthProviderGenerator.CLASS_NAME}.AuthOptions` + ); + } + break; + } + case "inferred": + break; + } + } + + if (unionMembers.length === 0) { + return; + } + + const unionTypeStr = unionMembers.join(" | "); + + context.sourceFile.addModule({ + name: CLASS_NAME, + isExported: true, + kind: StructureKind.Module, + statements: `export type ${AUTH_OPTIONS_TYPE_NAME} = { ${AUTH_FIELD_NAME}: ${unionTypeStr} };` + }); + } + + private writeClass(context: SdkContext): void { + const authSchemes = this.ir.auth.schemes; + + context.sourceFile.addClass({ + name: CLASS_NAME, + isExported: true, + implements: [getTextOfTsNode(context.coreUtilities.auth.AuthProvider._getReferenceToType())], + properties: [ + { + name: DELEGATE_FIELD_NAME, + type: getTextOfTsNode(context.coreUtilities.auth.AuthProvider._getReferenceToType()), + hasQuestionToken: false, + isReadonly: true, + scope: Scope.Private + } + ], + ctors: [ + { + parameters: [ + { + name: OPTIONS_FIELD_NAME, + type: "BaseClientOptions" + } + ], + statements: this.generateConstructorStatements(context, authSchemes) + } + ], + methods: [ + { + kind: StructureKind.Method, + scope: Scope.Public, + name: "getAuthRequest", + isAsync: true, + parameters: [ + { + name: "arg", + hasQuestionToken: true, + type: getTextOfTsNode( + ts.factory.createTypeLiteralNode([ + ts.factory.createPropertySignature( + undefined, + "endpointMetadata", + ts.factory.createToken(ts.SyntaxKind.QuestionToken), + context.coreUtilities.fetcher.EndpointMetadata._getReferenceToType() + ) + ]) + ) + } + ], + returnType: getTextOfTsNode( + ts.factory.createTypeReferenceNode("Promise", [ + context.coreUtilities.auth.AuthRequest._getReferenceToType() + ]) + ), + statements: `return this.${DELEGATE_FIELD_NAME}.getAuthRequest(arg);` + } + ] + }); + } + + private generateConstructorStatements(context: SdkContext, authSchemes: FernIr.AuthScheme[]): string { + const switchCases: string[] = []; + + for (const authScheme of authSchemes) { + const schemeKey = this.getAuthSchemeKey(authScheme); + + switch (authScheme.type) { + case "bearer": { + // Spread options and options.auth to create normalized options with auth fields at top level + switchCases.push(` + case "${schemeKey}": + this.${DELEGATE_FIELD_NAME} = new ${BearerAuthProviderGenerator.CLASS_NAME}({ ...${OPTIONS_FIELD_NAME}, ...${OPTIONS_FIELD_NAME}.${AUTH_FIELD_NAME} }); + break;`); + break; + } + case "basic": { + // Spread options and options.auth to create normalized options with auth fields at top level + switchCases.push(` + case "${schemeKey}": + this.${DELEGATE_FIELD_NAME} = new ${BasicAuthProviderGenerator.CLASS_NAME}({ ...${OPTIONS_FIELD_NAME}, ...${OPTIONS_FIELD_NAME}.${AUTH_FIELD_NAME} }); + break;`); + break; + } + case "header": { + // Spread options and options.auth to create normalized options with auth fields at top level + switchCases.push(` + case "${schemeKey}": + this.${DELEGATE_FIELD_NAME} = new ${HeaderAuthProviderGenerator.CLASS_NAME}({ ...${OPTIONS_FIELD_NAME}, ...${OPTIONS_FIELD_NAME}.${AUTH_FIELD_NAME} }); + break;`); + break; + } + case "oauth": { + if (context.generateOAuthClients) { + // Spread options and options.auth to create normalized options with auth fields at top level + switchCases.push(` + case "${schemeKey}": + this.${DELEGATE_FIELD_NAME} = new ${OAuthAuthProviderGenerator.CLASS_NAME}({ ...${OPTIONS_FIELD_NAME}, ...${OPTIONS_FIELD_NAME}.${AUTH_FIELD_NAME} }); + break;`); + } + break; + } + case "inferred": + break; + } + } + + return ` + if (${OPTIONS_FIELD_NAME}.${AUTH_FIELD_NAME} == null) { + this.${DELEGATE_FIELD_NAME} = new core.NoOpAuthProvider(); + return; + } + switch (${OPTIONS_FIELD_NAME}.${AUTH_FIELD_NAME}.type) {${switchCases.join("")} + default: { + const _exhaustive: never = ${OPTIONS_FIELD_NAME}.${AUTH_FIELD_NAME}; + throw new Error("Unknown auth type"); + } + }`; + } +} diff --git a/generators/typescript/sdk/client-class-generator/src/auth-provider/AnyAuthV2ProviderInstance.ts b/generators/typescript/sdk/client-class-generator/src/auth-provider/AnyAuthV2ProviderInstance.ts new file mode 100644 index 000000000000..6d869d764ba6 --- /dev/null +++ b/generators/typescript/sdk/client-class-generator/src/auth-provider/AnyAuthV2ProviderInstance.ts @@ -0,0 +1,119 @@ +import { AuthScheme, IntermediateRepresentation } from "@fern-fern/ir-sdk/api"; +import { getPropertyKey } from "@fern-typescript/commons"; +import { SdkContext } from "@fern-typescript/contexts"; +import { ts } from "ts-morph"; +import { AnyAuthV2ProviderGenerator } from "./AnyAuthV2ProviderGenerator"; +import { AuthProviderInstance } from "./AuthProviderInstance"; + +export class AnyAuthV2ProviderInstance implements AuthProviderInstance { + private readonly ir: IntermediateRepresentation; + + constructor(ir: IntermediateRepresentation) { + this.ir = ir; + } + + public instantiate({ context, params }: { context: SdkContext; params: ts.Expression[] }): ts.Expression { + context.importsManager.addImportFromRoot("auth/AnyAuthProvider", { + namedImports: ["AnyAuthProvider"] + }); + + return ts.factory.createNewExpression(ts.factory.createIdentifier("AnyAuthProvider"), undefined, params); + } + + public getSnippetProperties(context: SdkContext): ts.ObjectLiteralElementLike[] { + const authSchemes = this.ir.auth.schemes; + if (authSchemes.length === 0) { + return []; + } + + const firstScheme = authSchemes[0]; + if (firstScheme == null) { + return []; + } + + const schemeKey = this.getAuthSchemeKey(firstScheme); + const authObjectProperties: ts.ObjectLiteralElementLike[] = [ + ts.factory.createPropertyAssignment("type", ts.factory.createStringLiteral(schemeKey)) + ]; + + switch (firstScheme.type) { + case "bearer": { + const tokenKey = firstScheme.token.camelCase.safeName; + authObjectProperties.push( + ts.factory.createPropertyAssignment( + getPropertyKey(tokenKey), + ts.factory.createStringLiteral(`YOUR_${firstScheme.token.screamingSnakeCase.unsafeName}`) + ) + ); + break; + } + case "basic": { + const usernameKey = firstScheme.username.camelCase.safeName; + const passwordKey = firstScheme.password.camelCase.safeName; + authObjectProperties.push( + ts.factory.createPropertyAssignment( + getPropertyKey(usernameKey), + ts.factory.createStringLiteral(`YOUR_${firstScheme.username.screamingSnakeCase.unsafeName}`) + ), + ts.factory.createPropertyAssignment( + getPropertyKey(passwordKey), + ts.factory.createStringLiteral(`YOUR_${firstScheme.password.screamingSnakeCase.unsafeName}`) + ) + ); + break; + } + case "header": { + const headerKey = firstScheme.name.name.camelCase.safeName; + authObjectProperties.push( + ts.factory.createPropertyAssignment( + getPropertyKey(headerKey), + ts.factory.createStringLiteral(`YOUR_${firstScheme.name.name.screamingSnakeCase.unsafeName}`) + ) + ); + break; + } + case "oauth": { + const config = firstScheme.configuration; + if (config.type === "clientCredentials") { + authObjectProperties.push( + ts.factory.createPropertyAssignment( + "clientId", + ts.factory.createStringLiteral("YOUR_CLIENT_ID") + ), + ts.factory.createPropertyAssignment( + "clientSecret", + ts.factory.createStringLiteral("YOUR_CLIENT_SECRET") + ) + ); + } + break; + } + case "inferred": + break; + } + + return [ + ts.factory.createPropertyAssignment( + AnyAuthV2ProviderGenerator.AUTH_FIELD_NAME, + ts.factory.createObjectLiteralExpression(authObjectProperties, false) + ) + ]; + } + + private getAuthSchemeKey(authScheme: AuthScheme): string { + switch (authScheme.type) { + case "bearer": + return authScheme.key; + case "basic": + return authScheme.key; + case "header": + return authScheme.key; + case "oauth": + return authScheme.key; + case "inferred": + return authScheme.key; + default: + throw new Error(`Unknown auth scheme type: ${(authScheme as AuthScheme).type}`); + } + } +} diff --git a/generators/typescript/sdk/client-class-generator/src/auth-provider/OAuthAuthProviderGenerator.ts b/generators/typescript/sdk/client-class-generator/src/auth-provider/OAuthAuthProviderGenerator.ts index e2df6dd22d58..2622ffdb9477 100644 --- a/generators/typescript/sdk/client-class-generator/src/auth-provider/OAuthAuthProviderGenerator.ts +++ b/generators/typescript/sdk/client-class-generator/src/auth-provider/OAuthAuthProviderGenerator.ts @@ -223,7 +223,7 @@ export class OAuthAuthProviderGenerator implements AuthProviderGenerator { const constructorOptionsType = hasTokenOverride ? `${CLASS_NAME}.${OPTIONS_TYPE_NAME} & ${CLASS_NAME}.AuthOptions.ClientCredentials` - : getTextOfTsNode(this.getOptionsType()); + : `${CLASS_NAME}.${OPTIONS_TYPE_NAME} & ${CLASS_NAME}.${AUTH_OPTIONS_TYPE_NAME}`; let constructorStatements = ""; @@ -490,7 +490,7 @@ export class OAuthAuthProviderGenerator implements AuthProviderGenerator { parameters: [ { name: "options", - type: getTextOfTsNode(this.getOptionsType()) + type: `${CLASS_NAME}.${OPTIONS_TYPE_NAME} & Partial<${CLASS_NAME}.${AUTH_OPTIONS_TYPE_NAME}>` } ] }, diff --git a/generators/typescript/sdk/client-class-generator/src/auth-provider/index.ts b/generators/typescript/sdk/client-class-generator/src/auth-provider/index.ts index 0836a95e9dae..93d8a0b2034f 100644 --- a/generators/typescript/sdk/client-class-generator/src/auth-provider/index.ts +++ b/generators/typescript/sdk/client-class-generator/src/auth-provider/index.ts @@ -1,5 +1,7 @@ export { AnyAuthProviderGenerator } from "./AnyAuthProviderGenerator"; export { AnyAuthProviderInstance } from "./AnyAuthProviderInstance"; +export { AnyAuthV2ProviderGenerator } from "./AnyAuthV2ProviderGenerator"; +export { AnyAuthV2ProviderInstance } from "./AnyAuthV2ProviderInstance"; export type { AuthProviderGenerator } from "./AuthProviderGenerator"; export { AuthProviderInstance } from "./AuthProviderInstance"; export { BasicAuthProviderGenerator } from "./BasicAuthProviderGenerator"; diff --git a/generators/typescript/sdk/generator/src/SdkGenerator.ts b/generators/typescript/sdk/generator/src/SdkGenerator.ts index 6563ce5a7435..c226b55d7812 100644 --- a/generators/typescript/sdk/generator/src/SdkGenerator.ts +++ b/generators/typescript/sdk/generator/src/SdkGenerator.ts @@ -161,6 +161,7 @@ export declare namespace SdkGenerator { generateSubpackageExports: boolean; offsetSemantics: "item-index" | "page-index"; oauthTokenOverride: boolean; + anyAuth: "v1" | "v2"; } } @@ -474,13 +475,15 @@ export class SdkGenerator { generateEndpointMetadata: config.generateEndpointMetadata, parameterNaming: config.parameterNaming, offsetSemantics: config.offsetSemantics, - oauthTokenOverride: config.oauthTokenOverride + oauthTokenOverride: config.oauthTokenOverride, + anyAuth: config.anyAuth }); this.baseClientTypeGenerator = new BaseClientTypeGenerator({ ir: intermediateRepresentation, generateIdempotentRequestOptions: this.hasIdempotentEndpoints(), omitFernHeaders: config.omitFernHeaders, - oauthTokenOverride: config.oauthTokenOverride + oauthTokenOverride: config.oauthTokenOverride, + anyAuth: config.anyAuth }); this.websocketGenerator = new WebsocketClassGenerator({ intermediateRepresentation, @@ -1389,43 +1392,84 @@ export class SdkGenerator { const isAnyAuth = this.intermediateRepresentation.auth.requirement === "ANY"; if (isAnyAuth) { - // For ANY auth, we need to generate all individual auth providers first, - // then generate the AnyAuthProvider that aggregates them - for (const authScheme of this.intermediateRepresentation.auth.schemes) { - const authProvidersGenerator = new AuthProvidersGenerator({ + // Check if we should use v2 (discriminated union) auth + if (this.config.anyAuth === "v2") { + // For v2 auth, we need to generate all individual auth providers first, + // then generate the AnyAuthProvider that delegates to them + for (const authScheme of this.intermediateRepresentation.auth.schemes) { + const authProvidersGenerator = new AuthProvidersGenerator({ + ir: this.intermediateRepresentation, + authScheme, + neverThrowErrors: this.config.neverThrowErrors, + includeSerdeLayer: this.config.includeSerdeLayer, + oauthTokenOverride: this.config.oauthTokenOverride + }); + if (!authProvidersGenerator.shouldWriteFile()) { + continue; + } + this.withSourceFile({ + filepath: authProvidersGenerator.getFilePath(), + run: ({ sourceFile, importsManager }) => { + const context = this.generateSdkContext({ sourceFile, importsManager }); + authProvidersGenerator.writeToFile(context); + } + }); + } + + // Now generate the AnyAuthProvider (v2 style with discriminated union) + const anyAuthV2ProvidersGenerator = new AuthProvidersGenerator({ ir: this.intermediateRepresentation, - authScheme, + authScheme: { type: "anyAuthV2" }, neverThrowErrors: this.config.neverThrowErrors, includeSerdeLayer: this.config.includeSerdeLayer, oauthTokenOverride: this.config.oauthTokenOverride }); - if (!authProvidersGenerator.shouldWriteFile()) { - continue; + this.withSourceFile({ + filepath: anyAuthV2ProvidersGenerator.getFilePath(), + run: ({ sourceFile, importsManager }) => { + const context = this.generateSdkContext({ sourceFile, importsManager }); + anyAuthV2ProvidersGenerator.writeToFile(context); + } + }); + } else { + // For ANY auth, we need to generate all individual auth providers first, + // then generate the AnyAuthProvider that aggregates them + for (const authScheme of this.intermediateRepresentation.auth.schemes) { + const authProvidersGenerator = new AuthProvidersGenerator({ + ir: this.intermediateRepresentation, + authScheme, + neverThrowErrors: this.config.neverThrowErrors, + includeSerdeLayer: this.config.includeSerdeLayer, + oauthTokenOverride: this.config.oauthTokenOverride + }); + if (!authProvidersGenerator.shouldWriteFile()) { + continue; + } + this.withSourceFile({ + filepath: authProvidersGenerator.getFilePath(), + run: ({ sourceFile, importsManager }) => { + const context = this.generateSdkContext({ sourceFile, importsManager }); + authProvidersGenerator.writeToFile(context); + } + }); } + + // Now generate the AnyAuthProvider that aggregates all the individual providers + const anyAuthProvidersGenerator = new AuthProvidersGenerator({ + ir: this.intermediateRepresentation, + authScheme: { type: "any" }, + neverThrowErrors: this.config.neverThrowErrors, + includeSerdeLayer: this.config.includeSerdeLayer, + oauthTokenOverride: this.config.oauthTokenOverride + }); this.withSourceFile({ - filepath: authProvidersGenerator.getFilePath(), + filepath: anyAuthProvidersGenerator.getFilePath(), run: ({ sourceFile, importsManager }) => { const context = this.generateSdkContext({ sourceFile, importsManager }); - authProvidersGenerator.writeToFile(context); + anyAuthProvidersGenerator.writeToFile(context); } }); } - - // Now generate the AnyAuthProvider that aggregates all the individual providers - const anyAuthProvidersGenerator = new AuthProvidersGenerator({ - ir: this.intermediateRepresentation, - authScheme: { type: "any" }, - neverThrowErrors: this.config.neverThrowErrors, - includeSerdeLayer: this.config.includeSerdeLayer, - oauthTokenOverride: this.config.oauthTokenOverride - }); - this.withSourceFile({ - filepath: anyAuthProvidersGenerator.getFilePath(), - run: ({ sourceFile, importsManager }) => { - const context = this.generateSdkContext({ sourceFile, importsManager }); - anyAuthProvidersGenerator.writeToFile(context); - } - }); } else { // For non-ANY auth, generate auth providers as before for (const authScheme of this.intermediateRepresentation.auth.schemes) { diff --git a/generators/typescript/sdk/versions.yml b/generators/typescript/sdk/versions.yml index 2afd1ff373d7..3648d55979d8 100644 --- a/generators/typescript/sdk/versions.yml +++ b/generators/typescript/sdk/versions.yml @@ -1,4 +1,45 @@ # yaml-language-server: $schema=../../../fern-versions-yml.schema.json +- version: 3.38.0 + changelogEntry: + - summary: | + Add support for discriminated union auth configuration via the `anyAuth: v2` config option. + When enabled, users explicitly choose the auth type via a `type` field instead of the SDK trying each auth method (v1 behavior). + + To enable this feature, add the `anyAuth: v2` configuration to your _generators.yml_ file: + ```yaml + # In generators.yml + groups: + generators: + - name: fernapi/fern-typescript-sdk + config: + anyAuth: v2 + ``` + + Users can then instantiate the client with explicit auth type selection: + ```ts + // Bearer token auth + const client = new Client({ + auth: { + type: "Bearer", + token: "YOUR_BEARER_TOKEN" + } + }); + + // OAuth auth + const client = new Client({ + auth: { + type: "OAuth", + clientId: "...", + clientSecret: "..." + } + }); + ``` + + The `type` value is derived from the security scheme key in the API definition. + type: feat + createdAt: "2025-12-08" + irVersion: 62 + - version: 3.37.0 changelogEntry: - summary: | diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/.fern/metadata.json b/seed/ts-sdk/any-auth/discriminated-union-auth/.fern/metadata.json new file mode 100644 index 000000000000..84f736edb65c --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/.fern/metadata.json @@ -0,0 +1,8 @@ +{ + "cliVersion": "DUMMY", + "generatorName": "fernapi/fern-typescript-sdk", + "generatorVersion": "latest", + "generatorConfig": { + "anyAuth": "v2" + } +} diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/.github/workflows/ci.yml b/seed/ts-sdk/any-auth/discriminated-union-auth/.github/workflows/ci.yml new file mode 100644 index 000000000000..836106996595 --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/.github/workflows/ci.yml @@ -0,0 +1,78 @@ +name: ci + +on: [push] + +jobs: + compile: + runs-on: ubuntu-latest + + steps: + - name: Checkout repo + uses: actions/checkout@v6 + + - name: Set up node + uses: actions/setup-node@v6 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Compile + run: pnpm build + + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout repo + uses: actions/checkout@v6 + + - name: Set up node + uses: actions/setup-node@v6 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Test + run: pnpm test + + publish: + needs: [ compile, test ] + if: github.event_name == 'push' && contains(github.ref, 'refs/tags/') + runs-on: ubuntu-latest + steps: + - name: Checkout repo + uses: actions/checkout@v6 + + - name: Set up node + uses: actions/setup-node@v6 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build + run: pnpm build + + - name: Publish to npm + run: | + npm config set //registry.npmjs.org/:_authToken ${NPM_TOKEN} + publish() { # use latest npm to ensure OIDC support + npx -y npm@latest publish "$@" + } + if [[ ${GITHUB_REF} == *alpha* ]]; then + publish --access public --tag alpha + elif [[ ${GITHUB_REF} == *beta* ]]; then + publish --access public --tag beta + else + publish --access public + fi + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} \ No newline at end of file diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/.gitignore b/seed/ts-sdk/any-auth/discriminated-union-auth/.gitignore new file mode 100644 index 000000000000..72271e049c02 --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/.gitignore @@ -0,0 +1,3 @@ +node_modules +.DS_Store +/dist \ No newline at end of file diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/CONTRIBUTING.md b/seed/ts-sdk/any-auth/discriminated-union-auth/CONTRIBUTING.md new file mode 100644 index 000000000000..fe5bc2f77e0b --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/CONTRIBUTING.md @@ -0,0 +1,133 @@ +# Contributing + +Thanks for your interest in contributing to this SDK! This document provides guidelines for contributing to the project. + +## Getting Started + +### Prerequisites + +- Node.js 20 or higher +- pnpm package manager + +### Installation + +Install the project dependencies: + +```bash +pnpm install +``` + +### Building + +Build the project: + +```bash +pnpm build +``` + +### Testing + +Run the test suite: + +```bash +pnpm test +``` + +Run specific test types: +- `pnpm test:unit` - Run unit tests +- `pnpm test:wire` - Run wire/integration tests + +### Linting and Formatting + +Check code style: + +```bash +pnpm run lint +pnpm run format:check +``` + +Fix code style issues: + +```bash +pnpm run lint:fix +pnpm run format:fix +``` + +Or use the combined check command: + +```bash +pnpm run check:fix +``` + +## About Generated Code + +**Important**: Most files in this SDK are automatically generated by [Fern](https://buildwithfern.com) from the API definition. Direct modifications to generated files will be overwritten the next time the SDK is generated. + +### Generated Files + +The following directories contain generated code: +- `src/api/` - API client classes and types +- `src/serialization/` - Serialization/deserialization logic +- Most TypeScript files in `src/` + +### How to Customize + +If you need to customize the SDK, you have two options: + +#### Option 1: Use `.fernignore` + +For custom code that should persist across SDK regenerations: + +1. Create a `.fernignore` file in the project root +2. Add file patterns for files you want to preserve (similar to `.gitignore` syntax) +3. Add your custom code to those files + +Files listed in `.fernignore` will not be overwritten when the SDK is regenerated. + +For more information, see the [Fern documentation on custom code](https://buildwithfern.com/learn/sdks/overview/custom-code). + +#### Option 2: Contribute to the Generator + +If you want to change how code is generated for all users of this SDK: + +1. The TypeScript SDK generator lives in the [Fern repository](https://github.com/fern-api/fern) +2. Generator code is located at `generators/typescript/sdk/` +3. Follow the [Fern contributing guidelines](https://github.com/fern-api/fern/blob/main/CONTRIBUTING.md) +4. Submit a pull request with your changes to the generator + +This approach is best for: +- Bug fixes in generated code +- New features that would benefit all users +- Improvements to code generation patterns + +## Making Changes + +### Workflow + +1. Create a new branch for your changes +2. Make your modifications +3. Run tests to ensure nothing breaks: `pnpm test` +4. Run linting and formatting: `pnpm run check:fix` +5. Build the project: `pnpm build` +6. Commit your changes with a clear commit message +7. Push your branch and create a pull request + +### Commit Messages + +Write clear, descriptive commit messages that explain what changed and why. + +### Code Style + +This project uses automated code formatting and linting. Run `pnpm run check:fix` before committing to ensure your code meets the project's style guidelines. + +## Questions or Issues? + +If you have questions or run into issues: + +1. Check the [Fern documentation](https://buildwithfern.com) +2. Search existing [GitHub issues](https://github.com/fern-api/fern/issues) +3. Open a new issue if your question hasn't been addressed + +## License + +By contributing to this project, you agree that your contributions will be licensed under the same license as the project. diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/README.md b/seed/ts-sdk/any-auth/discriminated-union-auth/README.md new file mode 100644 index 000000000000..6e25ddbd17d7 --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/README.md @@ -0,0 +1,245 @@ +# Seed TypeScript Library + +[![fern shield](https://img.shields.io/badge/%F0%9F%8C%BF-Built%20with%20Fern-brightgreen)](https://buildwithfern.com?utm_source=github&utm_medium=github&utm_campaign=readme&utm_source=Seed%2FTypeScript) +[![npm shield](https://img.shields.io/npm/v/@fern/any-auth)](https://www.npmjs.com/package/@fern/any-auth) + +The Seed TypeScript library provides convenient access to the Seed APIs from TypeScript. + +## Installation + +```sh +npm i -s @fern/any-auth +``` + +## Reference + +A full reference for this library is available [here](./reference.md). + +## Usage + +Instantiate and use the client with the following: + +```typescript +import { SeedAnyAuthClient } from "@fern/any-auth"; + +const client = new SeedAnyAuthClient({ environment: "YOUR_BASE_URL", auth: { type: "Bearer", token: "YOUR_TOKEN" } }); +await client.auth.getToken({ + client_id: "client_id", + client_secret: "client_secret", + scope: "scope" +}); +``` + +## Request And Response Types + +The SDK exports all request and response types as TypeScript interfaces. Simply import them with the +following namespace: + +```typescript +import { SeedAnyAuth } from "@fern/any-auth"; + +const request: SeedAnyAuth.GetTokenRequest = { + ... +}; +``` + +## Exception Handling + +When the API returns a non-success status code (4xx or 5xx response), a subclass of the following error +will be thrown. + +```typescript +import { SeedAnyAuthError } from "@fern/any-auth"; + +try { + await client.auth.getToken(...); +} catch (err) { + if (err instanceof SeedAnyAuthError) { + console.log(err.statusCode); + console.log(err.message); + console.log(err.body); + console.log(err.rawResponse); + } +} +``` + +## Advanced + +### Additional Headers + +If you would like to send additional headers as part of the request, use the `headers` request option. + +```typescript +const response = await client.auth.getToken(..., { + headers: { + 'X-Custom-Header': 'custom value' + } +}); +``` + +### Additional Query String Parameters + +If you would like to send additional query string parameters as part of the request, use the `queryParams` request option. + +```typescript +const response = await client.auth.getToken(..., { + queryParams: { + 'customQueryParamKey': 'custom query param value' + } +}); +``` + +### Retries + +The SDK is instrumented with automatic retries with exponential backoff. A request will be retried as long +as the request is deemed retryable and the number of retry attempts has not grown larger than the configured +retry limit (default: 2). + +A request is deemed retryable when any of the following HTTP status codes is returned: + +- [408](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/408) (Timeout) +- [429](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429) (Too Many Requests) +- [5XX](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500) (Internal Server Errors) + +Use the `maxRetries` request option to configure this behavior. + +```typescript +const response = await client.auth.getToken(..., { + maxRetries: 0 // override maxRetries at the request level +}); +``` + +### Timeouts + +The SDK defaults to a 60 second timeout. Use the `timeoutInSeconds` option to configure this behavior. + +```typescript +const response = await client.auth.getToken(..., { + timeoutInSeconds: 30 // override timeout to 30s +}); +``` + +### Aborting Requests + +The SDK allows users to abort requests at any point by passing in an abort signal. + +```typescript +const controller = new AbortController(); +const response = await client.auth.getToken(..., { + abortSignal: controller.signal +}); +controller.abort(); // aborts the request +``` + +### Access Raw Response Data + +The SDK provides access to raw response data, including headers, through the `.withRawResponse()` method. +The `.withRawResponse()` method returns a promise that results to an object with a `data` and a `rawResponse` property. + +```typescript +const { data, rawResponse } = await client.auth.getToken(...).withRawResponse(); + +console.log(data); +console.log(rawResponse.headers['X-My-Header']); +``` + +### Logging + +The SDK supports logging. You can configure the logger by passing in a `logging` object to the client options. + +```typescript +import { SeedAnyAuthClient, logging } from "@fern/any-auth"; + +const client = new SeedAnyAuthClient({ + ... + logging: { + level: logging.LogLevel.Debug, // defaults to logging.LogLevel.Info + logger: new logging.ConsoleLogger(), // defaults to ConsoleLogger + silent: false, // defaults to true, set to false to enable logging + } +}); +``` +The `logging` object can have the following properties: +- `level`: The log level to use. Defaults to `logging.LogLevel.Info`. +- `logger`: The logger to use. Defaults to a `logging.ConsoleLogger`. +- `silent`: Whether to silence the logger. Defaults to `true`. + +The `level` property can be one of the following values: +- `logging.LogLevel.Debug` +- `logging.LogLevel.Info` +- `logging.LogLevel.Warn` +- `logging.LogLevel.Error` + +To provide a custom logger, you can pass in an object that implements the `logging.ILogger` interface. + +
+Custom logger examples + +Here's an example using the popular `winston` logging library. +```ts +import winston from 'winston'; + +const winstonLogger = winston.createLogger({...}); + +const logger: logging.ILogger = { + debug: (msg, ...args) => winstonLogger.debug(msg, ...args), + info: (msg, ...args) => winstonLogger.info(msg, ...args), + warn: (msg, ...args) => winstonLogger.warn(msg, ...args), + error: (msg, ...args) => winstonLogger.error(msg, ...args), +}; +``` + +Here's an example using the popular `pino` logging library. + +```ts +import pino from 'pino'; + +const pinoLogger = pino({...}); + +const logger: logging.ILogger = { + debug: (msg, ...args) => pinoLogger.debug(args, msg), + info: (msg, ...args) => pinoLogger.info(args, msg), + warn: (msg, ...args) => pinoLogger.warn(args, msg), + error: (msg, ...args) => pinoLogger.error(args, msg), +}; +``` +
+ + +### Runtime Compatibility + + +The SDK works in the following runtimes: + + + +- Node.js 18+ +- Vercel +- Cloudflare Workers +- Deno v1.25+ +- Bun 1.0+ +- React Native + +### Customizing Fetch Client + +The SDK provides a way for you to customize the underlying HTTP client / Fetch function. If you're running in an +unsupported environment, this provides a way for you to break glass and ensure the SDK works. + +```typescript +import { SeedAnyAuthClient } from "@fern/any-auth"; + +const client = new SeedAnyAuthClient({ + ... + fetcher: // provide your implementation here +}); +``` + +## Contributing + +While we value open-source contributions to this SDK, this library is generated programmatically. +Additions made directly to this library would have to be moved over to our generation code, +otherwise they would be overwritten upon the next generated release. Feel free to open a PR as +a proof of concept, but know that we will not be able to merge it as-is. We suggest opening +an issue first to discuss with us! + +On the other hand, contributions to the README are always very welcome! \ No newline at end of file diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/biome.json b/seed/ts-sdk/any-auth/discriminated-union-auth/biome.json new file mode 100644 index 000000000000..a777468e4ae2 --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/biome.json @@ -0,0 +1,74 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.3.1/schema.json", + "root": true, + "vcs": { + "enabled": false + }, + "files": { + "ignoreUnknown": true, + "includes": [ + "**", + "!!dist", + "!!**/dist", + "!!lib", + "!!**/lib", + "!!_tmp_*", + "!!**/_tmp_*", + "!!*.tmp", + "!!**/*.tmp", + "!!.tmp/", + "!!**/.tmp/", + "!!*.log", + "!!**/*.log", + "!!**/.DS_Store", + "!!**/Thumbs.db" + ] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 4, + "lineWidth": 120 + }, + "javascript": { + "formatter": { + "quoteStyle": "double" + } + }, + "assist": { + "enabled": true, + "actions": { + "source": { + "organizeImports": "on" + } + } + }, + "linter": { + "rules": { + "style": { + "useNodejsImportProtocol": "off" + }, + "suspicious": { + "noAssignInExpressions": "warn", + "noUselessEscapeInString": { + "level": "warn", + "fix": "none", + "options": {} + }, + "noThenProperty": "warn", + "useIterableCallbackReturn": "warn", + "noShadowRestrictedNames": "warn", + "noTsIgnore": { + "level": "warn", + "fix": "none", + "options": {} + }, + "noConfusingVoidType": { + "level": "warn", + "fix": "none", + "options": {} + } + } + } + } +} diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/package.json b/seed/ts-sdk/any-auth/discriminated-union-auth/package.json new file mode 100644 index 000000000000..2c799e812398 --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/package.json @@ -0,0 +1,66 @@ +{ + "name": "@fern/any-auth", + "version": "0.0.1", + "private": false, + "repository": "github:any-auth/fern", + "type": "commonjs", + "main": "./dist/cjs/index.js", + "module": "./dist/esm/index.mjs", + "types": "./dist/cjs/index.d.ts", + "exports": { + ".": { + "types": "./dist/cjs/index.d.ts", + "import": { + "types": "./dist/esm/index.d.mts", + "default": "./dist/esm/index.mjs" + }, + "require": { + "types": "./dist/cjs/index.d.ts", + "default": "./dist/cjs/index.js" + }, + "default": "./dist/cjs/index.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "dist", + "reference.md", + "README.md", + "LICENSE" + ], + "scripts": { + "format": "biome format --write --skip-parse-errors --no-errors-on-unmatched --max-diagnostics=none", + "format:check": "biome format --skip-parse-errors --no-errors-on-unmatched --max-diagnostics=none", + "lint": "biome lint --skip-parse-errors --no-errors-on-unmatched --max-diagnostics=none", + "lint:fix": "biome lint --fix --unsafe --skip-parse-errors --no-errors-on-unmatched --max-diagnostics=none", + "check": "biome check --skip-parse-errors --no-errors-on-unmatched --max-diagnostics=none", + "check:fix": "biome check --fix --unsafe --skip-parse-errors --no-errors-on-unmatched --max-diagnostics=none", + "build": "pnpm build:cjs && pnpm build:esm", + "build:cjs": "tsc --project ./tsconfig.cjs.json", + "build:esm": "tsc --project ./tsconfig.esm.json && node scripts/rename-to-esm-files.js dist/esm", + "test": "vitest", + "test:unit": "vitest --project unit", + "test:wire": "vitest --project wire" + }, + "dependencies": {}, + "devDependencies": { + "webpack": "^5.97.1", + "ts-loader": "^9.5.1", + "vitest": "^3.2.4", + "msw": "2.11.2", + "@types/node": "^18.19.70", + "typescript": "~5.7.2", + "@biomejs/biome": "2.3.1" + }, + "browser": { + "fs": false, + "os": false, + "path": false, + "stream": false + }, + "packageManager": "pnpm@10.20.0", + "engines": { + "node": ">=18.0.0" + }, + "sideEffects": false +} diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/pnpm-workspace.yaml b/seed/ts-sdk/any-auth/discriminated-union-auth/pnpm-workspace.yaml new file mode 100644 index 000000000000..6e4c395107df --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/pnpm-workspace.yaml @@ -0,0 +1 @@ +packages: ['.'] \ No newline at end of file diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/reference.md b/seed/ts-sdk/any-auth/discriminated-union-auth/reference.md new file mode 100644 index 000000000000..2b48432bd837 --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/reference.md @@ -0,0 +1,137 @@ +# Reference +## Auth +
client.auth.getToken({ ...params }) -> SeedAnyAuth.TokenResponse +
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```typescript +await client.auth.getToken({ + client_id: "client_id", + client_secret: "client_secret", + scope: "scope" +}); + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**request:** `SeedAnyAuth.GetTokenRequest` + +
+
+ +
+
+ +**requestOptions:** `AuthClient.RequestOptions` + +
+
+
+
+ + +
+
+
+ +## User +
client.user.get() -> SeedAnyAuth.User[] +
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```typescript +await client.user.get(); + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**requestOptions:** `UserClient.RequestOptions` + +
+
+
+
+ + +
+
+
+ +
client.user.getAdmins() -> SeedAnyAuth.User[] +
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```typescript +await client.user.getAdmins(); + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**requestOptions:** `UserClient.RequestOptions` + +
+
+
+
+ + +
+
+
diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/scripts/rename-to-esm-files.js b/seed/ts-sdk/any-auth/discriminated-union-auth/scripts/rename-to-esm-files.js new file mode 100644 index 000000000000..dc1df1cbbacb --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/scripts/rename-to-esm-files.js @@ -0,0 +1,123 @@ +#!/usr/bin/env node + +const fs = require("fs").promises; +const path = require("path"); + +const extensionMap = { + ".js": ".mjs", + ".d.ts": ".d.mts", +}; +const oldExtensions = Object.keys(extensionMap); + +async function findFiles(rootPath) { + const files = []; + + async function scan(directory) { + const entries = await fs.readdir(directory, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(directory, entry.name); + + if (entry.isDirectory()) { + if (entry.name !== "node_modules" && !entry.name.startsWith(".")) { + await scan(fullPath); + } + } else if (entry.isFile()) { + if (oldExtensions.some((ext) => entry.name.endsWith(ext))) { + files.push(fullPath); + } + } + } + } + + await scan(rootPath); + return files; +} + +async function updateFiles(files) { + const updatedFiles = []; + for (const file of files) { + const updated = await updateFileContents(file); + updatedFiles.push(updated); + } + + console.log(`Updated imports in ${updatedFiles.length} files.`); +} + +async function updateFileContents(file) { + const content = await fs.readFile(file, "utf8"); + + let newContent = content; + // Update each extension type defined in the map + for (const [oldExt, newExt] of Object.entries(extensionMap)) { + // Handle static imports/exports + const staticRegex = new RegExp(`(import|export)(.+from\\s+['"])(\\.\\.?\\/[^'"]+)(\\${oldExt})(['"])`, "g"); + newContent = newContent.replace(staticRegex, `$1$2$3${newExt}$5`); + + // Handle dynamic imports (yield import, await import, regular import()) + const dynamicRegex = new RegExp( + `(yield\\s+import|await\\s+import|import)\\s*\\(\\s*['"](\\.\\.\?\\/[^'"]+)(\\${oldExt})['"]\\s*\\)`, + "g", + ); + newContent = newContent.replace(dynamicRegex, `$1("$2${newExt}")`); + } + + if (content !== newContent) { + await fs.writeFile(file, newContent, "utf8"); + return true; + } + return false; +} + +async function renameFiles(files) { + let counter = 0; + for (const file of files) { + const ext = oldExtensions.find((ext) => file.endsWith(ext)); + const newExt = extensionMap[ext]; + + if (newExt) { + const newPath = file.slice(0, -ext.length) + newExt; + await fs.rename(file, newPath); + counter++; + } + } + + console.log(`Renamed ${counter} files.`); +} + +async function main() { + try { + const targetDir = process.argv[2]; + if (!targetDir) { + console.error("Please provide a target directory"); + process.exit(1); + } + + const targetPath = path.resolve(targetDir); + const targetStats = await fs.stat(targetPath); + + if (!targetStats.isDirectory()) { + console.error("The provided path is not a directory"); + process.exit(1); + } + + console.log(`Scanning directory: ${targetDir}`); + + const files = await findFiles(targetDir); + + if (files.length === 0) { + console.log("No matching files found."); + process.exit(0); + } + + console.log(`Found ${files.length} files.`); + await updateFiles(files); + await renameFiles(files); + console.log("\nDone!"); + } catch (error) { + console.error("An error occurred:", error.message); + process.exit(1); + } +} + +main(); diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/snippet.json b/seed/ts-sdk/any-auth/discriminated-union-auth/snippet.json new file mode 100644 index 000000000000..1b64148f24ff --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/snippet.json @@ -0,0 +1,38 @@ +{ + "endpoints": [ + { + "id": { + "path": "/token", + "method": "POST", + "identifier_override": "endpoint_auth.getToken" + }, + "snippet": { + "type": "typescript", + "client": "import { SeedAnyAuthClient } from \"@fern/any-auth\";\n\nconst client = new SeedAnyAuthClient({ environment: \"YOUR_BASE_URL\", auth: { type: \"Bearer\", token: \"YOUR_TOKEN\" } });\nawait client.auth.getToken({\n client_id: \"client_id\",\n client_secret: \"client_secret\",\n scope: \"scope\"\n});\n" + } + }, + { + "id": { + "path": "/users", + "method": "POST", + "identifier_override": "endpoint_user.get" + }, + "snippet": { + "type": "typescript", + "client": "import { SeedAnyAuthClient } from \"@fern/any-auth\";\n\nconst client = new SeedAnyAuthClient({ environment: \"YOUR_BASE_URL\", auth: { type: \"Bearer\", token: \"YOUR_TOKEN\" } });\nawait client.user.get();\n" + } + }, + { + "id": { + "path": "/admins", + "method": "GET", + "identifier_override": "endpoint_user.getAdmins" + }, + "snippet": { + "type": "typescript", + "client": "import { SeedAnyAuthClient } from \"@fern/any-auth\";\n\nconst client = new SeedAnyAuthClient({ environment: \"YOUR_BASE_URL\", auth: { type: \"Bearer\", token: \"YOUR_TOKEN\" } });\nawait client.user.getAdmins();\n" + } + } + ], + "types": {} +} \ No newline at end of file diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/src/BaseClient.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/src/BaseClient.ts new file mode 100644 index 000000000000..74580c46012e --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/src/BaseClient.ts @@ -0,0 +1,81 @@ +// This file was auto-generated by Fern from our API Definition. + +import { AnyAuthProvider } from "./auth/AnyAuthProvider.js"; +import { mergeHeaders } from "./core/headers.js"; +import * as core from "./core/index.js"; + +export type BaseClientOptions = { + environment: core.Supplier; + /** Specify a custom URL to connect the client to. */ + baseUrl?: core.Supplier; + /** Additional headers to include in requests. */ + headers?: Record | null | undefined>; + /** The default maximum time to wait for a response in seconds. */ + timeoutInSeconds?: number; + /** The default number of times to retry the request. Defaults to 2. */ + maxRetries?: number; + /** Provide a custom fetch implementation. Useful for platforms that don't have a built-in fetch or need a custom implementation. */ + fetch?: typeof fetch; + /** Configure logging for the client. */ + logging?: core.logging.LogConfig | core.logging.Logger; +} & AnyAuthProvider.AuthOptions; + +export interface BaseRequestOptions { + /** The maximum time to wait for a response in seconds. */ + timeoutInSeconds?: number; + /** The number of times to retry the request. Defaults to 2. */ + maxRetries?: number; + /** A hook to abort the request. */ + abortSignal?: AbortSignal; + /** Additional query string parameters to include in the request. */ + queryParams?: Record; + /** Additional headers to include in the request. */ + headers?: Record | null | undefined>; +} + +export type NormalizedClientOptions = T & { + logging: core.logging.Logger; + authProvider?: core.AuthProvider; +}; + +export type NormalizedClientOptionsWithAuth = NormalizedClientOptions & { + authProvider: core.AuthProvider; +}; + +export function normalizeClientOptions(options: T): NormalizedClientOptions { + const headers = mergeHeaders( + { + "X-Fern-Language": "JavaScript", + "X-Fern-SDK-Name": "@fern/any-auth", + "X-Fern-SDK-Version": "0.0.1", + "User-Agent": "@fern/any-auth/0.0.1", + "X-Fern-Runtime": core.RUNTIME.type, + "X-Fern-Runtime-Version": core.RUNTIME.version, + }, + options?.headers, + ); + + return { + ...options, + logging: core.logging.createLogger(options?.logging), + headers, + } as NormalizedClientOptions; +} + +export function normalizeClientOptionsWithAuth( + options: T, +): NormalizedClientOptionsWithAuth { + const normalized = normalizeClientOptions(options) as NormalizedClientOptionsWithAuth; + const _normalizedWithNoOpAuthProvider = withNoOpAuthProvider(normalized); + normalized.authProvider ??= new AnyAuthProvider(options); + return normalized; +} + +function withNoOpAuthProvider( + options: NormalizedClientOptions, +): NormalizedClientOptionsWithAuth { + return { + ...options, + authProvider: new core.NoOpAuthProvider(), + }; +} diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/src/Client.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/src/Client.ts new file mode 100644 index 000000000000..45c9039c9580 --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/src/Client.ts @@ -0,0 +1,30 @@ +// This file was auto-generated by Fern from our API Definition. + +import { AuthClient } from "./api/resources/auth/client/Client.js"; +import { UserClient } from "./api/resources/user/client/Client.js"; +import type { BaseClientOptions, BaseRequestOptions } from "./BaseClient.js"; +import { type NormalizedClientOptionsWithAuth, normalizeClientOptionsWithAuth } from "./BaseClient.js"; + +export declare namespace SeedAnyAuthClient { + export type Options = BaseClientOptions; + + export interface RequestOptions extends BaseRequestOptions {} +} + +export class SeedAnyAuthClient { + protected readonly _options: NormalizedClientOptionsWithAuth; + protected _auth: AuthClient | undefined; + protected _user: UserClient | undefined; + + constructor(options: SeedAnyAuthClient.Options) { + this._options = normalizeClientOptionsWithAuth(options); + } + + public get auth(): AuthClient { + return (this._auth ??= new AuthClient(this._options)); + } + + public get user(): UserClient { + return (this._user ??= new UserClient(this._options)); + } +} diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/src/api/index.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/src/api/index.ts new file mode 100644 index 000000000000..e445af0d831e --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/src/api/index.ts @@ -0,0 +1 @@ +export * from "./resources/index.js"; diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/src/api/resources/auth/client/Client.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/src/api/resources/auth/client/Client.ts new file mode 100644 index 000000000000..58a6dd30b9eb --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/src/api/resources/auth/client/Client.ts @@ -0,0 +1,79 @@ +// This file was auto-generated by Fern from our API Definition. + +import type { BaseClientOptions, BaseRequestOptions } from "../../../../BaseClient.js"; +import { type NormalizedClientOptions, normalizeClientOptions } from "../../../../BaseClient.js"; +import { mergeHeaders } from "../../../../core/headers.js"; +import * as core from "../../../../core/index.js"; +import { handleNonStatusCodeError } from "../../../../errors/handleNonStatusCodeError.js"; +import * as errors from "../../../../errors/index.js"; +import type * as SeedAnyAuth from "../../../index.js"; + +export declare namespace AuthClient { + export type Options = BaseClientOptions; + + export interface RequestOptions extends BaseRequestOptions {} +} + +export class AuthClient { + protected readonly _options: NormalizedClientOptions; + + constructor(options: AuthClient.Options) { + this._options = normalizeClientOptions(options); + } + + /** + * @param {SeedAnyAuth.GetTokenRequest} request + * @param {AuthClient.RequestOptions} requestOptions - Request-specific configuration. + * + * @example + * await client.auth.getToken({ + * client_id: "client_id", + * client_secret: "client_secret", + * scope: "scope" + * }) + */ + public getToken( + request: SeedAnyAuth.GetTokenRequest, + requestOptions?: AuthClient.RequestOptions, + ): core.HttpResponsePromise { + return core.HttpResponsePromise.fromPromise(this.__getToken(request, requestOptions)); + } + + private async __getToken( + request: SeedAnyAuth.GetTokenRequest, + requestOptions?: AuthClient.RequestOptions, + ): Promise> { + const _headers: core.Fetcher.Args["headers"] = mergeHeaders(this._options?.headers, requestOptions?.headers); + const _response = await core.fetcher({ + url: core.url.join( + (await core.Supplier.get(this._options.baseUrl)) ?? + (await core.Supplier.get(this._options.environment)), + "/token", + ), + method: "POST", + headers: _headers, + contentType: "application/json", + queryParameters: requestOptions?.queryParams, + requestType: "json", + body: { ...request, audience: "https://api.example.com", grant_type: "client_credentials" }, + timeoutMs: (requestOptions?.timeoutInSeconds ?? this._options?.timeoutInSeconds ?? 60) * 1000, + maxRetries: requestOptions?.maxRetries ?? this._options?.maxRetries, + abortSignal: requestOptions?.abortSignal, + fetchFn: this._options?.fetch, + logging: this._options.logging, + }); + if (_response.ok) { + return { data: _response.body as SeedAnyAuth.TokenResponse, rawResponse: _response.rawResponse }; + } + + if (_response.error.reason === "status-code") { + throw new errors.SeedAnyAuthError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + rawResponse: _response.rawResponse, + }); + } + + return handleNonStatusCodeError(_response.error, _response.rawResponse, "POST", "/token"); + } +} diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/src/api/resources/auth/client/index.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/src/api/resources/auth/client/index.ts new file mode 100644 index 000000000000..195f9aa8a846 --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/src/api/resources/auth/client/index.ts @@ -0,0 +1 @@ +export * from "./requests/index.js"; diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/src/api/resources/auth/client/requests/GetTokenRequest.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/src/api/resources/auth/client/requests/GetTokenRequest.ts new file mode 100644 index 000000000000..ffedfd9bf691 --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/src/api/resources/auth/client/requests/GetTokenRequest.ts @@ -0,0 +1,15 @@ +// This file was auto-generated by Fern from our API Definition. + +/** + * @example + * { + * client_id: "client_id", + * client_secret: "client_secret", + * scope: "scope" + * } + */ +export interface GetTokenRequest { + client_id: string; + client_secret: string; + scope?: string; +} diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/src/api/resources/auth/client/requests/index.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/src/api/resources/auth/client/requests/index.ts new file mode 100644 index 000000000000..3bf23ef4f9ce --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/src/api/resources/auth/client/requests/index.ts @@ -0,0 +1 @@ +export type { GetTokenRequest } from "./GetTokenRequest.js"; diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/src/api/resources/auth/index.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/src/api/resources/auth/index.ts new file mode 100644 index 000000000000..d9adb1af9a93 --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/src/api/resources/auth/index.ts @@ -0,0 +1,2 @@ +export * from "./client/index.js"; +export * from "./types/index.js"; diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/src/api/resources/auth/types/TokenResponse.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/src/api/resources/auth/types/TokenResponse.ts new file mode 100644 index 000000000000..b410f59491c1 --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/src/api/resources/auth/types/TokenResponse.ts @@ -0,0 +1,10 @@ +// This file was auto-generated by Fern from our API Definition. + +/** + * An OAuth token response. + */ +export interface TokenResponse { + access_token: string; + expires_in: number; + refresh_token?: string; +} diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/src/api/resources/auth/types/index.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/src/api/resources/auth/types/index.ts new file mode 100644 index 000000000000..67937a969229 --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/src/api/resources/auth/types/index.ts @@ -0,0 +1 @@ +export * from "./TokenResponse.js"; diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/src/api/resources/index.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/src/api/resources/index.ts new file mode 100644 index 000000000000..347b9d88821a --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/src/api/resources/index.ts @@ -0,0 +1,5 @@ +export * from "./auth/client/requests/index.js"; +export * as auth from "./auth/index.js"; +export * from "./auth/types/index.js"; +export * as user from "./user/index.js"; +export * from "./user/types/index.js"; diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/src/api/resources/user/client/Client.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/src/api/resources/user/client/Client.ts new file mode 100644 index 000000000000..370723e51bc6 --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/src/api/resources/user/client/Client.ts @@ -0,0 +1,119 @@ +// This file was auto-generated by Fern from our API Definition. + +import type { BaseClientOptions, BaseRequestOptions } from "../../../../BaseClient.js"; +import { type NormalizedClientOptionsWithAuth, normalizeClientOptionsWithAuth } from "../../../../BaseClient.js"; +import { mergeHeaders } from "../../../../core/headers.js"; +import * as core from "../../../../core/index.js"; +import { handleNonStatusCodeError } from "../../../../errors/handleNonStatusCodeError.js"; +import * as errors from "../../../../errors/index.js"; +import type * as SeedAnyAuth from "../../../index.js"; + +export declare namespace UserClient { + export type Options = BaseClientOptions; + + export interface RequestOptions extends BaseRequestOptions {} +} + +export class UserClient { + protected readonly _options: NormalizedClientOptionsWithAuth; + + constructor(options: UserClient.Options) { + this._options = normalizeClientOptionsWithAuth(options); + } + + /** + * @param {UserClient.RequestOptions} requestOptions - Request-specific configuration. + * + * @example + * await client.user.get() + */ + public get(requestOptions?: UserClient.RequestOptions): core.HttpResponsePromise { + return core.HttpResponsePromise.fromPromise(this.__get(requestOptions)); + } + + private async __get(requestOptions?: UserClient.RequestOptions): Promise> { + const _authRequest: core.AuthRequest = await this._options.authProvider.getAuthRequest(); + const _headers: core.Fetcher.Args["headers"] = mergeHeaders( + _authRequest.headers, + this._options?.headers, + requestOptions?.headers, + ); + const _response = await core.fetcher({ + url: core.url.join( + (await core.Supplier.get(this._options.baseUrl)) ?? + (await core.Supplier.get(this._options.environment)), + "users", + ), + method: "POST", + headers: _headers, + queryParameters: requestOptions?.queryParams, + timeoutMs: (requestOptions?.timeoutInSeconds ?? this._options?.timeoutInSeconds ?? 60) * 1000, + maxRetries: requestOptions?.maxRetries ?? this._options?.maxRetries, + abortSignal: requestOptions?.abortSignal, + fetchFn: this._options?.fetch, + logging: this._options.logging, + }); + if (_response.ok) { + return { data: _response.body as SeedAnyAuth.User[], rawResponse: _response.rawResponse }; + } + + if (_response.error.reason === "status-code") { + throw new errors.SeedAnyAuthError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + rawResponse: _response.rawResponse, + }); + } + + return handleNonStatusCodeError(_response.error, _response.rawResponse, "POST", "/users"); + } + + /** + * @param {UserClient.RequestOptions} requestOptions - Request-specific configuration. + * + * @example + * await client.user.getAdmins() + */ + public getAdmins(requestOptions?: UserClient.RequestOptions): core.HttpResponsePromise { + return core.HttpResponsePromise.fromPromise(this.__getAdmins(requestOptions)); + } + + private async __getAdmins( + requestOptions?: UserClient.RequestOptions, + ): Promise> { + const _authRequest: core.AuthRequest = await this._options.authProvider.getAuthRequest(); + const _headers: core.Fetcher.Args["headers"] = mergeHeaders( + _authRequest.headers, + this._options?.headers, + requestOptions?.headers, + ); + const _response = await core.fetcher({ + url: core.url.join( + (await core.Supplier.get(this._options.baseUrl)) ?? + (await core.Supplier.get(this._options.environment)), + "admins", + ), + method: "GET", + headers: _headers, + queryParameters: requestOptions?.queryParams, + timeoutMs: (requestOptions?.timeoutInSeconds ?? this._options?.timeoutInSeconds ?? 60) * 1000, + maxRetries: requestOptions?.maxRetries ?? this._options?.maxRetries, + abortSignal: requestOptions?.abortSignal, + fetchFn: this._options?.fetch, + logging: this._options.logging, + }); + if (_response.ok) { + return { data: _response.body as SeedAnyAuth.User[], rawResponse: _response.rawResponse }; + } + + if (_response.error.reason === "status-code") { + throw new errors.SeedAnyAuthError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + rawResponse: _response.rawResponse, + }); + } + + return handleNonStatusCodeError(_response.error, _response.rawResponse, "GET", "/admins"); + } +} diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/src/api/resources/user/client/index.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/src/api/resources/user/client/index.ts new file mode 100644 index 000000000000..cb0ff5c3b541 --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/src/api/resources/user/client/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/src/api/resources/user/index.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/src/api/resources/user/index.ts new file mode 100644 index 000000000000..d9adb1af9a93 --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/src/api/resources/user/index.ts @@ -0,0 +1,2 @@ +export * from "./client/index.js"; +export * from "./types/index.js"; diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/src/api/resources/user/types/User.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/src/api/resources/user/types/User.ts new file mode 100644 index 000000000000..699dba0c0b9e --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/src/api/resources/user/types/User.ts @@ -0,0 +1,6 @@ +// This file was auto-generated by Fern from our API Definition. + +export interface User { + id: string; + name: string; +} diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/src/api/resources/user/types/index.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/src/api/resources/user/types/index.ts new file mode 100644 index 000000000000..169437c217d9 --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/src/api/resources/user/types/index.ts @@ -0,0 +1 @@ +export * from "./User.js"; diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/src/auth/AnyAuthProvider.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/src/auth/AnyAuthProvider.ts new file mode 100644 index 000000000000..797a71807edb --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/src/auth/AnyAuthProvider.ts @@ -0,0 +1,46 @@ +// This file was auto-generated by Fern from our API Definition. + +import type { BaseClientOptions } from "../BaseClient.js"; +import * as core from "../core/index.js"; +import { BearerAuthProvider } from "./BearerAuthProvider.js"; +import { HeaderAuthProvider } from "./HeaderAuthProvider.js"; +import { OAuthAuthProvider } from "./OAuthAuthProvider.js"; + +export namespace AnyAuthProvider { + export type AuthOptions = { + auth: + | ({ type: "Bearer" } & BearerAuthProvider.AuthOptions) + | ({ type: "ApiKey" } & HeaderAuthProvider.AuthOptions) + | ({ type: "OAuth" } & OAuthAuthProvider.AuthOptions); + }; +} + +export class AnyAuthProvider implements core.AuthProvider { + private readonly delegate: core.AuthProvider; + + constructor(options: BaseClientOptions) { + if (options.auth == null) { + this.delegate = new core.NoOpAuthProvider(); + return; + } + switch (options.auth.type) { + case "Bearer": + this.delegate = new BearerAuthProvider({ ...options, ...options.auth }); + break; + case "ApiKey": + this.delegate = new HeaderAuthProvider({ ...options, ...options.auth }); + break; + case "OAuth": + this.delegate = new OAuthAuthProvider({ ...options, ...options.auth }); + break; + default: { + const _exhaustive: never = options.auth; + throw new Error("Unknown auth type"); + } + } + } + + public async getAuthRequest(arg?: { endpointMetadata?: core.EndpointMetadata }): Promise { + return this.delegate.getAuthRequest(arg); + } +} diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/src/auth/BearerAuthProvider.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/src/auth/BearerAuthProvider.ts new file mode 100644 index 000000000000..42aab712270a --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/src/auth/BearerAuthProvider.ts @@ -0,0 +1,38 @@ +// This file was auto-generated by Fern from our API Definition. + +import * as core from "../core/index.js"; +import * as errors from "../errors/index.js"; + +export namespace BearerAuthProvider { + export interface AuthOptions { + token?: core.Supplier | undefined; + } + + export interface Options extends AuthOptions {} +} + +export class BearerAuthProvider implements core.AuthProvider { + private readonly token: core.Supplier | undefined; + + constructor(options: BearerAuthProvider.Options) { + this.token = options.token; + } + + public static canCreate(options: BearerAuthProvider.Options): boolean { + return options.token != null || process.env?.MY_TOKEN != null; + } + + public async getAuthRequest(_arg?: { endpointMetadata?: core.EndpointMetadata }): Promise { + const token = (await core.Supplier.get(this.token)) ?? process.env?.MY_TOKEN; + if (token == null) { + throw new errors.SeedAnyAuthError({ + message: + "Please specify a token by either passing it in to the constructor or initializing a MY_TOKEN environment variable", + }); + } + + return { + headers: { Authorization: `Bearer ${token}` }, + }; + } +} diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/src/auth/HeaderAuthProvider.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/src/auth/HeaderAuthProvider.ts new file mode 100644 index 000000000000..7e604c7b87c1 --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/src/auth/HeaderAuthProvider.ts @@ -0,0 +1,40 @@ +// This file was auto-generated by Fern from our API Definition. + +import * as core from "../core/index.js"; +import * as errors from "../errors/index.js"; + +export namespace HeaderAuthProvider { + export interface AuthOptions { + apiKey?: core.Supplier | undefined; + } + + export interface Options extends AuthOptions {} +} + +export class HeaderAuthProvider implements core.AuthProvider { + private readonly headerValue: core.Supplier | undefined; + + constructor(options: HeaderAuthProvider.Options) { + this.headerValue = options.apiKey; + } + + public static canCreate(options: HeaderAuthProvider.Options): boolean { + return options.apiKey != null || process.env?.MY_API_KEY != null; + } + + public async getAuthRequest(_arg?: { endpointMetadata?: core.EndpointMetadata }): Promise { + const apiKey = (await core.Supplier.get(this.headerValue)) ?? process.env?.MY_API_KEY; + if (apiKey == null) { + throw new errors.SeedAnyAuthError({ + message: + "Please specify a apiKey by either passing it in to the constructor or initializing a MY_API_KEY environment variable", + }); + } + + const headerValue = apiKey; + + return { + headers: { "X-API-Key": headerValue }, + }; + } +} diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/src/auth/OAuthAuthProvider.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/src/auth/OAuthAuthProvider.ts new file mode 100644 index 000000000000..922899f7aba2 --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/src/auth/OAuthAuthProvider.ts @@ -0,0 +1,98 @@ +// This file was auto-generated by Fern from our API Definition. + +import { AuthClient } from "../api/resources/auth/client/Client.js"; +import type { BaseClientOptions } from "../BaseClient.js"; +import * as core from "../core/index.js"; +import * as errors from "../errors/index.js"; + +export class OAuthAuthProvider implements core.AuthProvider { + private readonly BUFFER_IN_MINUTES: number = 2; + private readonly _clientId: core.Supplier | undefined; + private readonly _clientSecret: core.Supplier | undefined; + private readonly _authClient: AuthClient; + private _accessToken: string | undefined; + private _expiresAt: Date; + private _refreshPromise: Promise | undefined; + + constructor(options: OAuthAuthProvider.Options & OAuthAuthProvider.AuthOptions) { + this._clientId = options.clientId; + this._clientSecret = options.clientSecret; + this._authClient = new AuthClient(options); + this._expiresAt = new Date(); + } + + public static canCreate(options: OAuthAuthProvider.Options & Partial): boolean { + return ( + (options.clientId != null || process.env?.MY_CLIENT_ID != null) && + (options.clientSecret != null || process.env?.MY_CLIENT_SECRET != null) + ); + } + + public async getAuthRequest(arg?: { endpointMetadata?: core.EndpointMetadata }): Promise { + const token = await this.getToken(arg); + + return { + headers: { + Authorization: `Bearer ${token}`, + }, + }; + } + + private async getToken(arg?: { endpointMetadata?: core.EndpointMetadata }): Promise { + if (this._accessToken && this._expiresAt > new Date()) { + return this._accessToken; + } + // If a refresh is already in progress, return the existing promise + if (this._refreshPromise != null) { + return this._refreshPromise; + } + return this.refresh(arg); + } + + private async refresh(_arg?: { endpointMetadata?: core.EndpointMetadata }): Promise { + this._refreshPromise = (async () => { + try { + const clientId = (await core.Supplier.get(this._clientId)) ?? process.env?.MY_CLIENT_ID; + if (clientId == null) { + throw new errors.SeedAnyAuthError({ + message: + "clientId is required; either pass it as an argument or set the MY_CLIENT_ID environment variable", + }); + } + + const clientSecret = (await core.Supplier.get(this._clientSecret)) ?? process.env?.MY_CLIENT_SECRET; + if (clientSecret == null) { + throw new errors.SeedAnyAuthError({ + message: + "clientSecret is required; either pass it as an argument or set the MY_CLIENT_SECRET environment variable", + }); + } + const tokenResponse = await this._authClient.getToken({ + client_id: clientId, + client_secret: clientSecret, + }); + + this._accessToken = tokenResponse.access_token; + this._expiresAt = this.getExpiresAt(tokenResponse.expires_in, this.BUFFER_IN_MINUTES); + return this._accessToken; + } finally { + this._refreshPromise = undefined; + } + })(); + return this._refreshPromise; + } + + private getExpiresAt(expiresInSeconds: number, bufferInMinutes: number): Date { + const now = new Date(); + return new Date(now.getTime() + expiresInSeconds * 1000 - bufferInMinutes * 60 * 1000); + } +} + +export namespace OAuthAuthProvider { + export interface AuthOptions { + clientId?: core.Supplier | undefined; + clientSecret?: core.Supplier | undefined; + } + + export type Options = BaseClientOptions; +} diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/src/auth/index.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/src/auth/index.ts new file mode 100644 index 000000000000..560101e6bba4 --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/src/auth/index.ts @@ -0,0 +1,4 @@ +export { AnyAuthProvider } from "./AnyAuthProvider.js"; +export { BearerAuthProvider } from "./BearerAuthProvider.js"; +export { HeaderAuthProvider } from "./HeaderAuthProvider.js"; +export { OAuthAuthProvider } from "./OAuthAuthProvider.js"; diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/auth/AuthProvider.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/auth/AuthProvider.ts new file mode 100644 index 000000000000..895a50ff30da --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/auth/AuthProvider.ts @@ -0,0 +1,6 @@ +import type { EndpointMetadata } from "../fetcher/EndpointMetadata.js"; +import type { AuthRequest } from "./AuthRequest.js"; + +export interface AuthProvider { + getAuthRequest(arg?: { endpointMetadata?: EndpointMetadata }): Promise; +} diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/auth/AuthRequest.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/auth/AuthRequest.ts new file mode 100644 index 000000000000..f6218b42211e --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/auth/AuthRequest.ts @@ -0,0 +1,9 @@ +/** + * Request parameters for authentication requests. + */ +export interface AuthRequest { + /** + * The headers to be included in the request. + */ + headers: Record; +} diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/auth/BasicAuth.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/auth/BasicAuth.ts new file mode 100644 index 000000000000..a64235910062 --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/auth/BasicAuth.ts @@ -0,0 +1,32 @@ +import { base64Decode, base64Encode } from "../base64.js"; + +export interface BasicAuth { + username: string; + password: string; +} + +const BASIC_AUTH_HEADER_PREFIX = /^Basic /i; + +export const BasicAuth = { + toAuthorizationHeader: (basicAuth: BasicAuth | undefined): string | undefined => { + if (basicAuth == null) { + return undefined; + } + const token = base64Encode(`${basicAuth.username}:${basicAuth.password}`); + return `Basic ${token}`; + }, + fromAuthorizationHeader: (header: string): BasicAuth => { + const credentials = header.replace(BASIC_AUTH_HEADER_PREFIX, ""); + const decoded = base64Decode(credentials); + const [username, ...passwordParts] = decoded.split(":"); + const password = passwordParts.length > 0 ? passwordParts.join(":") : undefined; + + if (username == null || password == null) { + throw new Error("Invalid basic auth"); + } + return { + username, + password, + }; + }, +}; diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/auth/BearerToken.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/auth/BearerToken.ts new file mode 100644 index 000000000000..c44a06c38f06 --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/auth/BearerToken.ts @@ -0,0 +1,20 @@ +export type BearerToken = string; + +const BEARER_AUTH_HEADER_PREFIX = /^Bearer /i; + +function toAuthorizationHeader(token: string | undefined): string | undefined { + if (token == null) { + return undefined; + } + return `Bearer ${token}`; +} + +export const BearerToken: { + toAuthorizationHeader: typeof toAuthorizationHeader; + fromAuthorizationHeader: (header: string) => BearerToken; +} = { + toAuthorizationHeader: toAuthorizationHeader, + fromAuthorizationHeader: (header: string): BearerToken => { + return header.replace(BEARER_AUTH_HEADER_PREFIX, "").trim() as BearerToken; + }, +}; diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/auth/NoOpAuthProvider.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/auth/NoOpAuthProvider.ts new file mode 100644 index 000000000000..5b7acfd2bd8b --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/auth/NoOpAuthProvider.ts @@ -0,0 +1,8 @@ +import type { AuthProvider } from "./AuthProvider.js"; +import type { AuthRequest } from "./AuthRequest.js"; + +export class NoOpAuthProvider implements AuthProvider { + public getAuthRequest(): Promise { + return Promise.resolve({ headers: {} }); + } +} diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/auth/index.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/auth/index.ts new file mode 100644 index 000000000000..2215b227709e --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/auth/index.ts @@ -0,0 +1,5 @@ +export type { AuthProvider } from "./AuthProvider.js"; +export type { AuthRequest } from "./AuthRequest.js"; +export { BasicAuth } from "./BasicAuth.js"; +export { BearerToken } from "./BearerToken.js"; +export { NoOpAuthProvider } from "./NoOpAuthProvider.js"; diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/base64.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/base64.ts new file mode 100644 index 000000000000..448a0db638a6 --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/base64.ts @@ -0,0 +1,27 @@ +function base64ToBytes(base64: string): Uint8Array { + const binString = atob(base64); + return Uint8Array.from(binString, (m) => m.codePointAt(0)!); +} + +function bytesToBase64(bytes: Uint8Array): string { + const binString = String.fromCodePoint(...bytes); + return btoa(binString); +} + +export function base64Encode(input: string): string { + if (typeof Buffer !== "undefined") { + return Buffer.from(input, "utf8").toString("base64"); + } + + const bytes = new TextEncoder().encode(input); + return bytesToBase64(bytes); +} + +export function base64Decode(input: string): string { + if (typeof Buffer !== "undefined") { + return Buffer.from(input, "base64").toString("utf8"); + } + + const bytes = base64ToBytes(input); + return new TextDecoder().decode(bytes); +} diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/exports.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/exports.ts new file mode 100644 index 000000000000..69296d7100d6 --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/exports.ts @@ -0,0 +1 @@ +export * from "./logging/exports.js"; diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/fetcher/APIResponse.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/fetcher/APIResponse.ts new file mode 100644 index 000000000000..97ab83c2b195 --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/fetcher/APIResponse.ts @@ -0,0 +1,23 @@ +import type { RawResponse } from "./RawResponse.js"; + +/** + * The response of an API call. + * It is a successful response or a failed response. + */ +export type APIResponse = SuccessfulResponse | FailedResponse; + +export interface SuccessfulResponse { + ok: true; + body: T; + /** + * @deprecated Use `rawResponse` instead + */ + headers?: Record; + rawResponse: RawResponse; +} + +export interface FailedResponse { + ok: false; + error: T; + rawResponse: RawResponse; +} diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/fetcher/BinaryResponse.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/fetcher/BinaryResponse.ts new file mode 100644 index 000000000000..bca7f4c77981 --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/fetcher/BinaryResponse.ts @@ -0,0 +1,34 @@ +export type BinaryResponse = { + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/bodyUsed) */ + bodyUsed: Response["bodyUsed"]; + /** + * Returns a ReadableStream of the response body. + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/body) + */ + stream: () => Response["body"]; + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/arrayBuffer) */ + arrayBuffer: () => ReturnType; + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/blob) */ + blob: () => ReturnType; + /** + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/bytes) + * Some versions of the Fetch API may not support this method. + */ + bytes?(): ReturnType; +}; + +export function getBinaryResponse(response: Response): BinaryResponse { + const binaryResponse: BinaryResponse = { + get bodyUsed() { + return response.bodyUsed; + }, + stream: () => response.body, + arrayBuffer: response.arrayBuffer.bind(response), + blob: response.blob.bind(response), + }; + if ("bytes" in response && typeof response.bytes === "function") { + binaryResponse.bytes = response.bytes.bind(response); + } + + return binaryResponse; +} diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/fetcher/EndpointMetadata.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/fetcher/EndpointMetadata.ts new file mode 100644 index 000000000000..998d68f5c20c --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/fetcher/EndpointMetadata.ts @@ -0,0 +1,13 @@ +export type SecuritySchemeKey = string; +/** + * A collection of security schemes, where the key is the name of the security scheme and the value is the list of scopes required for that scheme. + * All schemes in the collection must be satisfied for authentication to be successful. + */ +export type SecuritySchemeCollection = Record; +export type AuthScope = string; +export type EndpointMetadata = { + /** + * An array of security scheme collections. Each collection represents an alternative way to authenticate. + */ + security?: SecuritySchemeCollection[]; +}; diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/fetcher/EndpointSupplier.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/fetcher/EndpointSupplier.ts new file mode 100644 index 000000000000..8079841c4062 --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/fetcher/EndpointSupplier.ts @@ -0,0 +1,14 @@ +import type { EndpointMetadata } from "./EndpointMetadata.js"; +import type { Supplier } from "./Supplier.js"; + +type EndpointSupplierFn = (arg: { endpointMetadata: EndpointMetadata }) => T | Promise; +export type EndpointSupplier = Supplier | EndpointSupplierFn; +export const EndpointSupplier = { + get: async (supplier: EndpointSupplier, arg: { endpointMetadata: EndpointMetadata }): Promise => { + if (typeof supplier === "function") { + return (supplier as EndpointSupplierFn)(arg); + } else { + return supplier; + } + }, +}; diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/fetcher/Fetcher.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/fetcher/Fetcher.ts new file mode 100644 index 000000000000..58bb0e3ef7d9 --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/fetcher/Fetcher.ts @@ -0,0 +1,391 @@ +import { toJson } from "../json.js"; +import { createLogger, type LogConfig, type Logger } from "../logging/logger.js"; +import type { APIResponse } from "./APIResponse.js"; +import { createRequestUrl } from "./createRequestUrl.js"; +import type { EndpointMetadata } from "./EndpointMetadata.js"; +import { EndpointSupplier } from "./EndpointSupplier.js"; +import { getErrorResponseBody } from "./getErrorResponseBody.js"; +import { getFetchFn } from "./getFetchFn.js"; +import { getRequestBody } from "./getRequestBody.js"; +import { getResponseBody } from "./getResponseBody.js"; +import { Headers } from "./Headers.js"; +import { makeRequest } from "./makeRequest.js"; +import { abortRawResponse, toRawResponse, unknownRawResponse } from "./RawResponse.js"; +import { requestWithRetries } from "./requestWithRetries.js"; + +export type FetchFunction = (args: Fetcher.Args) => Promise>; + +export declare namespace Fetcher { + export interface Args { + url: string; + method: string; + contentType?: string; + headers?: Record | null | undefined>; + queryParameters?: Record; + body?: unknown; + timeoutMs?: number; + maxRetries?: number; + withCredentials?: boolean; + abortSignal?: AbortSignal; + requestType?: "json" | "file" | "bytes" | "form" | "other"; + responseType?: "json" | "blob" | "sse" | "streaming" | "text" | "arrayBuffer" | "binary-response"; + duplex?: "half"; + endpointMetadata?: EndpointMetadata; + fetchFn?: typeof fetch; + logging?: LogConfig | Logger; + } + + export type Error = FailedStatusCodeError | NonJsonError | BodyIsNullError | TimeoutError | UnknownError; + + export interface FailedStatusCodeError { + reason: "status-code"; + statusCode: number; + body: unknown; + } + + export interface NonJsonError { + reason: "non-json"; + statusCode: number; + rawBody: string; + } + + export interface BodyIsNullError { + reason: "body-is-null"; + statusCode: number; + } + + export interface TimeoutError { + reason: "timeout"; + } + + export interface UnknownError { + reason: "unknown"; + errorMessage: string; + } +} + +const SENSITIVE_HEADERS = new Set([ + "authorization", + "www-authenticate", + "x-api-key", + "api-key", + "apikey", + "x-api-token", + "x-auth-token", + "auth-token", + "cookie", + "set-cookie", + "proxy-authorization", + "proxy-authenticate", + "x-csrf-token", + "x-xsrf-token", + "x-session-token", + "x-access-token", +]); + +function redactHeaders(headers: Headers | Record): Record { + const filtered: Record = {}; + for (const [key, value] of headers instanceof Headers ? headers.entries() : Object.entries(headers)) { + if (SENSITIVE_HEADERS.has(key.toLowerCase())) { + filtered[key] = "[REDACTED]"; + } else { + filtered[key] = value; + } + } + return filtered; +} + +const SENSITIVE_QUERY_PARAMS = new Set([ + "api_key", + "api-key", + "apikey", + "token", + "access_token", + "access-token", + "auth_token", + "auth-token", + "password", + "passwd", + "secret", + "api_secret", + "api-secret", + "apisecret", + "key", + "session", + "session_id", + "session-id", +]); + +function redactQueryParameters(queryParameters?: Record): Record | undefined { + if (queryParameters == null) { + return queryParameters; + } + const redacted: Record = {}; + for (const [key, value] of Object.entries(queryParameters)) { + if (SENSITIVE_QUERY_PARAMS.has(key.toLowerCase())) { + redacted[key] = "[REDACTED]"; + } else { + redacted[key] = value; + } + } + return redacted; +} + +function redactUrl(url: string): string { + const protocolIndex = url.indexOf("://"); + if (protocolIndex === -1) return url; + + const afterProtocol = protocolIndex + 3; + + // Find the first delimiter that marks the end of the authority section + const pathStart = url.indexOf("/", afterProtocol); + let queryStart = url.indexOf("?", afterProtocol); + let fragmentStart = url.indexOf("#", afterProtocol); + + const firstDelimiter = Math.min( + pathStart === -1 ? url.length : pathStart, + queryStart === -1 ? url.length : queryStart, + fragmentStart === -1 ? url.length : fragmentStart, + ); + + // Find the LAST @ before the delimiter (handles multiple @ in credentials) + let atIndex = -1; + for (let i = afterProtocol; i < firstDelimiter; i++) { + if (url[i] === "@") { + atIndex = i; + } + } + + if (atIndex !== -1) { + url = `${url.slice(0, afterProtocol)}[REDACTED]@${url.slice(atIndex + 1)}`; + } + + // Recalculate queryStart since url might have changed + queryStart = url.indexOf("?"); + if (queryStart === -1) return url; + + fragmentStart = url.indexOf("#", queryStart); + const queryEnd = fragmentStart !== -1 ? fragmentStart : url.length; + const queryString = url.slice(queryStart + 1, queryEnd); + + if (queryString.length === 0) return url; + + // FAST PATH: Quick check if any sensitive keywords present + // Using indexOf is faster than regex for simple substring matching + const lower = queryString.toLowerCase(); + const hasSensitive = + lower.includes("token") || + lower.includes("key") || + lower.includes("password") || + lower.includes("passwd") || + lower.includes("secret") || + lower.includes("session") || + lower.includes("auth"); + + if (!hasSensitive) { + return url; + } + + // SLOW PATH: Parse and redact + const redactedParams: string[] = []; + const params = queryString.split("&"); + + for (const param of params) { + const equalIndex = param.indexOf("="); + if (equalIndex === -1) { + redactedParams.push(param); + continue; + } + + const key = param.slice(0, equalIndex); + let shouldRedact = SENSITIVE_QUERY_PARAMS.has(key.toLowerCase()); + + if (!shouldRedact && key.includes("%")) { + try { + const decodedKey = decodeURIComponent(key); + shouldRedact = SENSITIVE_QUERY_PARAMS.has(decodedKey.toLowerCase()); + } catch {} + } + + redactedParams.push(shouldRedact ? `${key}=[REDACTED]` : param); + } + + return url.slice(0, queryStart + 1) + redactedParams.join("&") + url.slice(queryEnd); +} + +async function getHeaders(args: Fetcher.Args): Promise { + const newHeaders: Headers = new Headers(); + + newHeaders.set( + "Accept", + args.responseType === "json" ? "application/json" : args.responseType === "text" ? "text/plain" : "*/*", + ); + if (args.body !== undefined && args.contentType != null) { + newHeaders.set("Content-Type", args.contentType); + } + + if (args.headers == null) { + return newHeaders; + } + + for (const [key, value] of Object.entries(args.headers)) { + const result = await EndpointSupplier.get(value, { endpointMetadata: args.endpointMetadata ?? {} }); + if (typeof result === "string") { + newHeaders.set(key, result); + continue; + } + if (result == null) { + continue; + } + newHeaders.set(key, `${result}`); + } + return newHeaders; +} + +export async function fetcherImpl(args: Fetcher.Args): Promise> { + const url = createRequestUrl(args.url, args.queryParameters); + const requestBody: BodyInit | undefined = await getRequestBody({ + body: args.body, + type: args.requestType ?? "other", + }); + const fetchFn = args.fetchFn ?? (await getFetchFn()); + const headers = await getHeaders(args); + const logger = createLogger(args.logging); + + if (logger.isDebug()) { + const metadata = { + method: args.method, + url: redactUrl(url), + headers: redactHeaders(headers), + queryParameters: redactQueryParameters(args.queryParameters), + hasBody: requestBody != null, + }; + logger.debug("Making HTTP request", metadata); + } + + try { + const response = await requestWithRetries( + async () => + makeRequest( + fetchFn, + url, + args.method, + headers, + requestBody, + args.timeoutMs, + args.abortSignal, + args.withCredentials, + args.duplex, + ), + args.maxRetries, + ); + + if (response.status >= 200 && response.status < 400) { + if (logger.isDebug()) { + const metadata = { + method: args.method, + url: redactUrl(url), + statusCode: response.status, + responseHeaders: redactHeaders(response.headers), + }; + logger.debug("HTTP request succeeded", metadata); + } + const body = await getResponseBody(response, args.responseType); + return { + ok: true, + body: body as R, + headers: response.headers, + rawResponse: toRawResponse(response), + }; + } else { + if (logger.isError()) { + const metadata = { + method: args.method, + url: redactUrl(url), + statusCode: response.status, + responseHeaders: redactHeaders(Object.fromEntries(response.headers.entries())), + }; + logger.error("HTTP request failed with error status", metadata); + } + return { + ok: false, + error: { + reason: "status-code", + statusCode: response.status, + body: await getErrorResponseBody(response), + }, + rawResponse: toRawResponse(response), + }; + } + } catch (error) { + if (args.abortSignal?.aborted) { + if (logger.isError()) { + const metadata = { + method: args.method, + url: redactUrl(url), + }; + logger.error("HTTP request was aborted", metadata); + } + return { + ok: false, + error: { + reason: "unknown", + errorMessage: "The user aborted a request", + }, + rawResponse: abortRawResponse, + }; + } else if (error instanceof Error && error.name === "AbortError") { + if (logger.isError()) { + const metadata = { + method: args.method, + url: redactUrl(url), + timeoutMs: args.timeoutMs, + }; + logger.error("HTTP request timed out", metadata); + } + return { + ok: false, + error: { + reason: "timeout", + }, + rawResponse: abortRawResponse, + }; + } else if (error instanceof Error) { + if (logger.isError()) { + const metadata = { + method: args.method, + url: redactUrl(url), + errorMessage: error.message, + }; + logger.error("HTTP request failed with error", metadata); + } + return { + ok: false, + error: { + reason: "unknown", + errorMessage: error.message, + }, + rawResponse: unknownRawResponse, + }; + } + + if (logger.isError()) { + const metadata = { + method: args.method, + url: redactUrl(url), + error: toJson(error), + }; + logger.error("HTTP request failed with unknown error", metadata); + } + return { + ok: false, + error: { + reason: "unknown", + errorMessage: toJson(error), + }, + rawResponse: unknownRawResponse, + }; + } +} + +export const fetcher: FetchFunction = fetcherImpl; diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/fetcher/Headers.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/fetcher/Headers.ts new file mode 100644 index 000000000000..af841aa24f55 --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/fetcher/Headers.ts @@ -0,0 +1,93 @@ +let Headers: typeof globalThis.Headers; + +if (typeof globalThis.Headers !== "undefined") { + Headers = globalThis.Headers; +} else { + Headers = class Headers implements Headers { + private headers: Map; + + constructor(init?: HeadersInit) { + this.headers = new Map(); + + if (init) { + if (init instanceof Headers) { + init.forEach((value, key) => this.append(key, value)); + } else if (Array.isArray(init)) { + for (const [key, value] of init) { + if (typeof key === "string" && typeof value === "string") { + this.append(key, value); + } else { + throw new TypeError("Each header entry must be a [string, string] tuple"); + } + } + } else { + for (const [key, value] of Object.entries(init)) { + if (typeof value === "string") { + this.append(key, value); + } else { + throw new TypeError("Header values must be strings"); + } + } + } + } + } + + append(name: string, value: string): void { + const key = name.toLowerCase(); + const existing = this.headers.get(key) || []; + this.headers.set(key, [...existing, value]); + } + + delete(name: string): void { + const key = name.toLowerCase(); + this.headers.delete(key); + } + + get(name: string): string | null { + const key = name.toLowerCase(); + const values = this.headers.get(key); + return values ? values.join(", ") : null; + } + + has(name: string): boolean { + const key = name.toLowerCase(); + return this.headers.has(key); + } + + set(name: string, value: string): void { + const key = name.toLowerCase(); + this.headers.set(key, [value]); + } + + forEach(callbackfn: (value: string, key: string, parent: Headers) => void, thisArg?: unknown): void { + const boundCallback = thisArg ? callbackfn.bind(thisArg) : callbackfn; + this.headers.forEach((values, key) => boundCallback(values.join(", "), key, this)); + } + + getSetCookie(): string[] { + return this.headers.get("set-cookie") || []; + } + + *entries(): HeadersIterator<[string, string]> { + for (const [key, values] of this.headers.entries()) { + yield [key, values.join(", ")]; + } + } + + *keys(): HeadersIterator { + yield* this.headers.keys(); + } + + *values(): HeadersIterator { + for (const values of this.headers.values()) { + yield values.join(", "); + } + } + + [Symbol.iterator](): HeadersIterator<[string, string]> { + return this.entries(); + } + }; +} + +export { Headers }; diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/fetcher/HttpResponsePromise.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/fetcher/HttpResponsePromise.ts new file mode 100644 index 000000000000..692ca7d795f0 --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/fetcher/HttpResponsePromise.ts @@ -0,0 +1,116 @@ +import type { WithRawResponse } from "./RawResponse.js"; + +/** + * A promise that returns the parsed response and lets you retrieve the raw response too. + */ +export class HttpResponsePromise extends Promise { + private innerPromise: Promise>; + private unwrappedPromise: Promise | undefined; + + private constructor(promise: Promise>) { + // Initialize with a no-op to avoid premature parsing + super((resolve) => { + resolve(undefined as unknown as T); + }); + this.innerPromise = promise; + } + + /** + * Creates an `HttpResponsePromise` from a function that returns a promise. + * + * @param fn - A function that returns a promise resolving to a `WithRawResponse` object. + * @param args - Arguments to pass to the function. + * @returns An `HttpResponsePromise` instance. + */ + public static fromFunction Promise>, T>( + fn: F, + ...args: Parameters + ): HttpResponsePromise { + return new HttpResponsePromise(fn(...args)); + } + + /** + * Creates a function that returns an `HttpResponsePromise` from a function that returns a promise. + * + * @param fn - A function that returns a promise resolving to a `WithRawResponse` object. + * @returns A function that returns an `HttpResponsePromise` instance. + */ + public static interceptFunction< + F extends (...args: never[]) => Promise>, + T = Awaited>["data"], + >(fn: F): (...args: Parameters) => HttpResponsePromise { + return (...args: Parameters): HttpResponsePromise => { + return HttpResponsePromise.fromPromise(fn(...args)); + }; + } + + /** + * Creates an `HttpResponsePromise` from an existing promise. + * + * @param promise - A promise resolving to a `WithRawResponse` object. + * @returns An `HttpResponsePromise` instance. + */ + public static fromPromise(promise: Promise>): HttpResponsePromise { + return new HttpResponsePromise(promise); + } + + /** + * Creates an `HttpResponsePromise` from an executor function. + * + * @param executor - A function that takes resolve and reject callbacks to create a promise. + * @returns An `HttpResponsePromise` instance. + */ + public static fromExecutor( + executor: (resolve: (value: WithRawResponse) => void, reject: (reason?: unknown) => void) => void, + ): HttpResponsePromise { + const promise = new Promise>(executor); + return new HttpResponsePromise(promise); + } + + /** + * Creates an `HttpResponsePromise` from a resolved result. + * + * @param result - A `WithRawResponse` object to resolve immediately. + * @returns An `HttpResponsePromise` instance. + */ + public static fromResult(result: WithRawResponse): HttpResponsePromise { + const promise = Promise.resolve(result); + return new HttpResponsePromise(promise); + } + + private unwrap(): Promise { + if (!this.unwrappedPromise) { + this.unwrappedPromise = this.innerPromise.then(({ data }) => data); + } + return this.unwrappedPromise; + } + + /** @inheritdoc */ + public override then( + onfulfilled?: ((value: T) => TResult1 | PromiseLike) | null, + onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null, + ): Promise { + return this.unwrap().then(onfulfilled, onrejected); + } + + /** @inheritdoc */ + public override catch( + onrejected?: ((reason: unknown) => TResult | PromiseLike) | null, + ): Promise { + return this.unwrap().catch(onrejected); + } + + /** @inheritdoc */ + public override finally(onfinally?: (() => void) | null): Promise { + return this.unwrap().finally(onfinally); + } + + /** + * Retrieves the data and raw response. + * + * @returns A promise resolving to a `WithRawResponse` object. + */ + public async withRawResponse(): Promise> { + return await this.innerPromise; + } +} diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/fetcher/RawResponse.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/fetcher/RawResponse.ts new file mode 100644 index 000000000000..37fb44e2aa99 --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/fetcher/RawResponse.ts @@ -0,0 +1,61 @@ +import { Headers } from "./Headers.js"; + +/** + * The raw response from the fetch call excluding the body. + */ +export type RawResponse = Omit< + { + [K in keyof Response as Response[K] extends Function ? never : K]: Response[K]; // strips out functions + }, + "ok" | "body" | "bodyUsed" +>; // strips out body and bodyUsed + +/** + * A raw response indicating that the request was aborted. + */ +export const abortRawResponse: RawResponse = { + headers: new Headers(), + redirected: false, + status: 499, + statusText: "Client Closed Request", + type: "error", + url: "", +} as const; + +/** + * A raw response indicating an unknown error. + */ +export const unknownRawResponse: RawResponse = { + headers: new Headers(), + redirected: false, + status: 0, + statusText: "Unknown Error", + type: "error", + url: "", +} as const; + +/** + * Converts a `RawResponse` object into a `RawResponse` by extracting its properties, + * excluding the `body` and `bodyUsed` fields. + * + * @param response - The `RawResponse` object to convert. + * @returns A `RawResponse` object containing the extracted properties of the input response. + */ +export function toRawResponse(response: Response): RawResponse { + return { + headers: response.headers, + redirected: response.redirected, + status: response.status, + statusText: response.statusText, + type: response.type, + url: response.url, + }; +} + +/** + * Creates a `RawResponse` from a standard `Response` object. + */ +export interface WithRawResponse { + readonly data: T; + readonly rawResponse: RawResponse; +} diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/fetcher/Supplier.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/fetcher/Supplier.ts new file mode 100644 index 000000000000..867c931c02f4 --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/fetcher/Supplier.ts @@ -0,0 +1,11 @@ +export type Supplier = T | Promise | (() => T | Promise); + +export const Supplier = { + get: async (supplier: Supplier): Promise => { + if (typeof supplier === "function") { + return (supplier as () => T)(); + } else { + return supplier; + } + }, +}; diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/fetcher/createRequestUrl.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/fetcher/createRequestUrl.ts new file mode 100644 index 000000000000..88e13265e112 --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/fetcher/createRequestUrl.ts @@ -0,0 +1,6 @@ +import { toQueryString } from "../url/qs.js"; + +export function createRequestUrl(baseUrl: string, queryParameters?: Record): string { + const queryString = toQueryString(queryParameters, { arrayFormat: "repeat" }); + return queryString ? `${baseUrl}?${queryString}` : baseUrl; +} diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/fetcher/getErrorResponseBody.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/fetcher/getErrorResponseBody.ts new file mode 100644 index 000000000000..7cf4e623c2f5 --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/fetcher/getErrorResponseBody.ts @@ -0,0 +1,33 @@ +import { fromJson } from "../json.js"; +import { getResponseBody } from "./getResponseBody.js"; + +export async function getErrorResponseBody(response: Response): Promise { + let contentType = response.headers.get("Content-Type")?.toLowerCase(); + if (contentType == null || contentType.length === 0) { + return getResponseBody(response); + } + + if (contentType.indexOf(";") !== -1) { + contentType = contentType.split(";")[0]?.trim() ?? ""; + } + switch (contentType) { + case "application/hal+json": + case "application/json": + case "application/ld+json": + case "application/problem+json": + case "application/vnd.api+json": + case "text/json": { + const text = await response.text(); + return text.length > 0 ? fromJson(text) : undefined; + } + default: + if (contentType.startsWith("application/vnd.") && contentType.endsWith("+json")) { + const text = await response.text(); + return text.length > 0 ? fromJson(text) : undefined; + } + + // Fallback to plain text if content type is not recognized + // Even if no body is present, the response will be an empty string + return await response.text(); + } +} diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/fetcher/getFetchFn.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/fetcher/getFetchFn.ts new file mode 100644 index 000000000000..9f845b956392 --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/fetcher/getFetchFn.ts @@ -0,0 +1,3 @@ +export async function getFetchFn(): Promise { + return fetch; +} diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/fetcher/getHeader.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/fetcher/getHeader.ts new file mode 100644 index 000000000000..50f922b0e87f --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/fetcher/getHeader.ts @@ -0,0 +1,8 @@ +export function getHeader(headers: Record, header: string): string | undefined { + for (const [headerKey, headerValue] of Object.entries(headers)) { + if (headerKey.toLowerCase() === header.toLowerCase()) { + return headerValue; + } + } + return undefined; +} diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/fetcher/getRequestBody.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/fetcher/getRequestBody.ts new file mode 100644 index 000000000000..91d9d81f50e5 --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/fetcher/getRequestBody.ts @@ -0,0 +1,20 @@ +import { toJson } from "../json.js"; +import { toQueryString } from "../url/qs.js"; + +export declare namespace GetRequestBody { + interface Args { + body: unknown; + type: "json" | "file" | "bytes" | "form" | "other"; + } +} + +export async function getRequestBody({ body, type }: GetRequestBody.Args): Promise { + if (type === "form") { + return toQueryString(body, { arrayFormat: "repeat", encode: true }); + } + if (type.includes("json")) { + return toJson(body); + } else { + return body as BodyInit; + } +} diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/fetcher/getResponseBody.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/fetcher/getResponseBody.ts new file mode 100644 index 000000000000..708d55728f2b --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/fetcher/getResponseBody.ts @@ -0,0 +1,58 @@ +import { fromJson } from "../json.js"; +import { getBinaryResponse } from "./BinaryResponse.js"; + +export async function getResponseBody(response: Response, responseType?: string): Promise { + switch (responseType) { + case "binary-response": + return getBinaryResponse(response); + case "blob": + return await response.blob(); + case "arrayBuffer": + return await response.arrayBuffer(); + case "sse": + if (response.body == null) { + return { + ok: false, + error: { + reason: "body-is-null", + statusCode: response.status, + }, + }; + } + return response.body; + case "streaming": + if (response.body == null) { + return { + ok: false, + error: { + reason: "body-is-null", + statusCode: response.status, + }, + }; + } + + return response.body; + + case "text": + return await response.text(); + } + + // if responseType is "json" or not specified, try to parse as JSON + const text = await response.text(); + if (text.length > 0) { + try { + const responseBody = fromJson(text); + return responseBody; + } catch (_err) { + return { + ok: false, + error: { + reason: "non-json", + statusCode: response.status, + rawBody: text, + }, + }; + } + } + return undefined; +} diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/fetcher/index.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/fetcher/index.ts new file mode 100644 index 000000000000..c3bc6da20f49 --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/fetcher/index.ts @@ -0,0 +1,11 @@ +export type { APIResponse } from "./APIResponse.js"; +export type { BinaryResponse } from "./BinaryResponse.js"; +export type { EndpointMetadata } from "./EndpointMetadata.js"; +export { EndpointSupplier } from "./EndpointSupplier.js"; +export type { Fetcher, FetchFunction } from "./Fetcher.js"; +export { fetcher } from "./Fetcher.js"; +export { getHeader } from "./getHeader.js"; +export { HttpResponsePromise } from "./HttpResponsePromise.js"; +export type { RawResponse, WithRawResponse } from "./RawResponse.js"; +export { abortRawResponse, toRawResponse, unknownRawResponse } from "./RawResponse.js"; +export { Supplier } from "./Supplier.js"; diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/fetcher/makeRequest.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/fetcher/makeRequest.ts new file mode 100644 index 000000000000..921565eb0063 --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/fetcher/makeRequest.ts @@ -0,0 +1,42 @@ +import { anySignal, getTimeoutSignal } from "./signals.js"; + +export const makeRequest = async ( + fetchFn: (url: string, init: RequestInit) => Promise, + url: string, + method: string, + headers: Headers | Record, + requestBody: BodyInit | undefined, + timeoutMs?: number, + abortSignal?: AbortSignal, + withCredentials?: boolean, + duplex?: "half", +): Promise => { + const signals: AbortSignal[] = []; + + let timeoutAbortId: ReturnType | undefined; + if (timeoutMs != null) { + const { signal, abortId } = getTimeoutSignal(timeoutMs); + timeoutAbortId = abortId; + signals.push(signal); + } + + if (abortSignal != null) { + signals.push(abortSignal); + } + const newSignals = anySignal(signals); + const response = await fetchFn(url, { + method: method, + headers, + body: requestBody, + signal: newSignals, + credentials: withCredentials ? "include" : undefined, + // @ts-ignore + duplex, + }); + + if (timeoutAbortId != null) { + clearTimeout(timeoutAbortId); + } + + return response; +}; diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/fetcher/requestWithRetries.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/fetcher/requestWithRetries.ts new file mode 100644 index 000000000000..1f689688c4b2 --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/fetcher/requestWithRetries.ts @@ -0,0 +1,64 @@ +const INITIAL_RETRY_DELAY = 1000; // in milliseconds +const MAX_RETRY_DELAY = 60000; // in milliseconds +const DEFAULT_MAX_RETRIES = 2; +const JITTER_FACTOR = 0.2; // 20% random jitter + +function addPositiveJitter(delay: number): number { + const jitterMultiplier = 1 + Math.random() * JITTER_FACTOR; + return delay * jitterMultiplier; +} + +function addSymmetricJitter(delay: number): number { + const jitterMultiplier = 1 + (Math.random() - 0.5) * JITTER_FACTOR; + return delay * jitterMultiplier; +} + +function getRetryDelayFromHeaders(response: Response, retryAttempt: number): number { + const retryAfter = response.headers.get("Retry-After"); + if (retryAfter) { + const retryAfterSeconds = parseInt(retryAfter, 10); + if (!Number.isNaN(retryAfterSeconds) && retryAfterSeconds > 0) { + return Math.min(retryAfterSeconds * 1000, MAX_RETRY_DELAY); + } + + const retryAfterDate = new Date(retryAfter); + if (!Number.isNaN(retryAfterDate.getTime())) { + const delay = retryAfterDate.getTime() - Date.now(); + if (delay > 0) { + return Math.min(Math.max(delay, 0), MAX_RETRY_DELAY); + } + } + } + + const rateLimitReset = response.headers.get("X-RateLimit-Reset"); + if (rateLimitReset) { + const resetTime = parseInt(rateLimitReset, 10); + if (!Number.isNaN(resetTime)) { + const delay = resetTime * 1000 - Date.now(); + if (delay > 0) { + return addPositiveJitter(Math.min(delay, MAX_RETRY_DELAY)); + } + } + } + + return addSymmetricJitter(Math.min(INITIAL_RETRY_DELAY * 2 ** retryAttempt, MAX_RETRY_DELAY)); +} + +export async function requestWithRetries( + requestFn: () => Promise, + maxRetries: number = DEFAULT_MAX_RETRIES, +): Promise { + let response: Response = await requestFn(); + + for (let i = 0; i < maxRetries; ++i) { + if ([408, 429].includes(response.status) || response.status >= 500) { + const delay = getRetryDelayFromHeaders(response, i); + + await new Promise((resolve) => setTimeout(resolve, delay)); + response = await requestFn(); + } else { + break; + } + } + return response!; +} diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/fetcher/signals.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/fetcher/signals.ts new file mode 100644 index 000000000000..7bd3757ec3a7 --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/fetcher/signals.ts @@ -0,0 +1,26 @@ +const TIMEOUT = "timeout"; + +export function getTimeoutSignal(timeoutMs: number): { signal: AbortSignal; abortId: ReturnType } { + const controller = new AbortController(); + const abortId = setTimeout(() => controller.abort(TIMEOUT), timeoutMs); + return { signal: controller.signal, abortId }; +} + +export function anySignal(...args: AbortSignal[] | [AbortSignal[]]): AbortSignal { + const signals = (args.length === 1 && Array.isArray(args[0]) ? args[0] : args) as AbortSignal[]; + + const controller = new AbortController(); + + for (const signal of signals) { + if (signal.aborted) { + controller.abort((signal as any)?.reason); + break; + } + + signal.addEventListener("abort", () => controller.abort((signal as any)?.reason), { + signal: controller.signal, + }); + } + + return controller.signal; +} diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/headers.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/headers.ts new file mode 100644 index 000000000000..78ed8b500c95 --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/headers.ts @@ -0,0 +1,35 @@ +export function mergeHeaders( + ...headersArray: (Record | null | undefined)[] +): Record { + const result: Record = {}; + + for (const [key, value] of headersArray + .filter((headers) => headers != null) + .flatMap((headers) => Object.entries(headers))) { + const insensitiveKey = key.toLowerCase(); + if (value != null) { + result[insensitiveKey] = value; + } else if (insensitiveKey in result) { + delete result[insensitiveKey]; + } + } + + return result; +} + +export function mergeOnlyDefinedHeaders( + ...headersArray: (Record | null | undefined)[] +): Record { + const result: Record = {}; + + for (const [key, value] of headersArray + .filter((headers) => headers != null) + .flatMap((headers) => Object.entries(headers))) { + const insensitiveKey = key.toLowerCase(); + if (value != null) { + result[insensitiveKey] = value; + } + } + + return result; +} diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/index.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/index.ts new file mode 100644 index 000000000000..92290bfadcac --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/index.ts @@ -0,0 +1,6 @@ +export * from "./auth/index.js"; +export * from "./base64.js"; +export * from "./fetcher/index.js"; +export * as logging from "./logging/index.js"; +export * from "./runtime/index.js"; +export * as url from "./url/index.js"; diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/json.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/json.ts new file mode 100644 index 000000000000..c052f3249f4f --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/json.ts @@ -0,0 +1,27 @@ +/** + * Serialize a value to JSON + * @param value A JavaScript value, usually an object or array, to be converted. + * @param replacer A function that transforms the results. + * @param space Adds indentation, white space, and line break characters to the return-value JSON text to make it easier to read. + * @returns JSON string + */ +export const toJson = ( + value: unknown, + replacer?: (this: unknown, key: string, value: unknown) => unknown, + space?: string | number, +): string => { + return JSON.stringify(value, replacer, space); +}; + +/** + * Parse JSON string to object, array, or other type + * @param text A valid JSON string. + * @param reviver A function that transforms the results. This function is called for each member of the object. If a member contains nested objects, the nested objects are transformed before the parent object is. + * @returns Parsed object, array, or other type + */ +export function fromJson( + text: string, + reviver?: (this: unknown, key: string, value: unknown) => unknown, +): T { + return JSON.parse(text, reviver); +} diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/logging/exports.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/logging/exports.ts new file mode 100644 index 000000000000..88f6c00db0cf --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/logging/exports.ts @@ -0,0 +1,19 @@ +import * as logger from "./logger.js"; + +export namespace logging { + /** + * Configuration for logger instances. + */ + export type LogConfig = logger.LogConfig; + export type LogLevel = logger.LogLevel; + export const LogLevel: typeof logger.LogLevel = logger.LogLevel; + export type ILogger = logger.ILogger; + /** + * Console logger implementation that outputs to the console. + */ + export type ConsoleLogger = logger.ConsoleLogger; + /** + * Console logger implementation that outputs to the console. + */ + export const ConsoleLogger: typeof logger.ConsoleLogger = logger.ConsoleLogger; +} diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/logging/index.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/logging/index.ts new file mode 100644 index 000000000000..d81cc32c40f9 --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/logging/index.ts @@ -0,0 +1 @@ +export * from "./logger.js"; diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/logging/logger.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/logging/logger.ts new file mode 100644 index 000000000000..a3f3673cda93 --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/logging/logger.ts @@ -0,0 +1,203 @@ +export const LogLevel = { + Debug: "debug", + Info: "info", + Warn: "warn", + Error: "error", +} as const; +export type LogLevel = (typeof LogLevel)[keyof typeof LogLevel]; +const logLevelMap: Record = { + [LogLevel.Debug]: 1, + [LogLevel.Info]: 2, + [LogLevel.Warn]: 3, + [LogLevel.Error]: 4, +}; + +export interface ILogger { + /** + * Logs a debug message. + * @param message - The message to log + * @param args - Additional arguments to log + */ + debug(message: string, ...args: unknown[]): void; + /** + * Logs an info message. + * @param message - The message to log + * @param args - Additional arguments to log + */ + info(message: string, ...args: unknown[]): void; + /** + * Logs a warning message. + * @param message - The message to log + * @param args - Additional arguments to log + */ + warn(message: string, ...args: unknown[]): void; + /** + * Logs an error message. + * @param message - The message to log + * @param args - Additional arguments to log + */ + error(message: string, ...args: unknown[]): void; +} + +/** + * Configuration for logger initialization. + */ +export interface LogConfig { + /** + * Minimum log level to output. + * @default LogLevel.Info + */ + level?: LogLevel; + /** + * Logger implementation to use. + * @default new ConsoleLogger() + */ + logger?: ILogger; + /** + * Whether logging should be silenced. + * @default true + */ + silent?: boolean; +} + +/** + * Default console-based logger implementation. + */ +export class ConsoleLogger implements ILogger { + debug(message: string, ...args: unknown[]): void { + console.debug(message, ...args); + } + info(message: string, ...args: unknown[]): void { + console.info(message, ...args); + } + warn(message: string, ...args: unknown[]): void { + console.warn(message, ...args); + } + error(message: string, ...args: unknown[]): void { + console.error(message, ...args); + } +} + +/** + * Logger class that provides level-based logging functionality. + */ +export class Logger { + private readonly level: number; + private readonly logger: ILogger; + private readonly silent: boolean; + + /** + * Creates a new logger instance. + * @param config - Logger configuration + */ + constructor(config: Required) { + this.level = logLevelMap[config.level]; + this.logger = config.logger; + this.silent = config.silent; + } + + /** + * Checks if a log level should be output based on configuration. + * @param level - The log level to check + * @returns True if the level should be logged + */ + public shouldLog(level: LogLevel): boolean { + return !this.silent && this.level <= logLevelMap[level]; + } + + /** + * Checks if debug logging is enabled. + * @returns True if debug logs should be output + */ + public isDebug(): boolean { + return this.shouldLog(LogLevel.Debug); + } + + /** + * Logs a debug message if debug logging is enabled. + * @param message - The message to log + * @param args - Additional arguments to log + */ + public debug(message: string, ...args: unknown[]): void { + if (this.isDebug()) { + this.logger.debug(message, ...args); + } + } + + /** + * Checks if info logging is enabled. + * @returns True if info logs should be output + */ + public isInfo(): boolean { + return this.shouldLog(LogLevel.Info); + } + + /** + * Logs an info message if info logging is enabled. + * @param message - The message to log + * @param args - Additional arguments to log + */ + public info(message: string, ...args: unknown[]): void { + if (this.isInfo()) { + this.logger.info(message, ...args); + } + } + + /** + * Checks if warning logging is enabled. + * @returns True if warning logs should be output + */ + public isWarn(): boolean { + return this.shouldLog(LogLevel.Warn); + } + + /** + * Logs a warning message if warning logging is enabled. + * @param message - The message to log + * @param args - Additional arguments to log + */ + public warn(message: string, ...args: unknown[]): void { + if (this.isWarn()) { + this.logger.warn(message, ...args); + } + } + + /** + * Checks if error logging is enabled. + * @returns True if error logs should be output + */ + public isError(): boolean { + return this.shouldLog(LogLevel.Error); + } + + /** + * Logs an error message if error logging is enabled. + * @param message - The message to log + * @param args - Additional arguments to log + */ + public error(message: string, ...args: unknown[]): void { + if (this.isError()) { + this.logger.error(message, ...args); + } + } +} + +export function createLogger(config?: LogConfig | Logger): Logger { + if (config == null) { + return defaultLogger; + } + if (config instanceof Logger) { + return config; + } + config = config ?? {}; + config.level ??= LogLevel.Info; + config.logger ??= new ConsoleLogger(); + config.silent ??= true; + return new Logger(config as Required); +} + +const defaultLogger: Logger = new Logger({ + level: LogLevel.Info, + logger: new ConsoleLogger(), + silent: true, +}); diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/runtime/index.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/runtime/index.ts new file mode 100644 index 000000000000..cfab23f9a834 --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/runtime/index.ts @@ -0,0 +1 @@ +export { RUNTIME } from "./runtime.js"; diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/runtime/runtime.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/runtime/runtime.ts new file mode 100644 index 000000000000..56ebbb87c4d3 --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/runtime/runtime.ts @@ -0,0 +1,134 @@ +interface DenoGlobal { + version: { + deno: string; + }; +} + +interface BunGlobal { + version: string; +} + +declare const Deno: DenoGlobal | undefined; +declare const Bun: BunGlobal | undefined; +declare const EdgeRuntime: string | undefined; +declare const self: typeof globalThis.self & { + importScripts?: unknown; +}; + +/** + * A constant that indicates which environment and version the SDK is running in. + */ +export const RUNTIME: Runtime = evaluateRuntime(); + +export interface Runtime { + type: "browser" | "web-worker" | "deno" | "bun" | "node" | "react-native" | "unknown" | "workerd" | "edge-runtime"; + version?: string; + parsedVersion?: number; +} + +function evaluateRuntime(): Runtime { + /** + * A constant that indicates whether the environment the code is running is a Web Browser. + */ + const isBrowser = typeof window !== "undefined" && typeof window.document !== "undefined"; + if (isBrowser) { + return { + type: "browser", + version: window.navigator.userAgent, + }; + } + + /** + * A constant that indicates whether the environment the code is running is Cloudflare. + * https://developers.cloudflare.com/workers/runtime-apis/web-standards/#navigatoruseragent + */ + const isCloudflare = typeof globalThis !== "undefined" && globalThis?.navigator?.userAgent === "Cloudflare-Workers"; + if (isCloudflare) { + return { + type: "workerd", + }; + } + + /** + * A constant that indicates whether the environment the code is running is Edge Runtime. + * https://vercel.com/docs/functions/runtimes/edge-runtime#check-if-you're-running-on-the-edge-runtime + */ + const isEdgeRuntime = typeof EdgeRuntime === "string"; + if (isEdgeRuntime) { + return { + type: "edge-runtime", + }; + } + + /** + * A constant that indicates whether the environment the code is running is a Web Worker. + */ + const isWebWorker = + typeof self === "object" && + typeof self?.importScripts === "function" && + (self.constructor?.name === "DedicatedWorkerGlobalScope" || + self.constructor?.name === "ServiceWorkerGlobalScope" || + self.constructor?.name === "SharedWorkerGlobalScope"); + if (isWebWorker) { + return { + type: "web-worker", + }; + } + + /** + * A constant that indicates whether the environment the code is running is Deno. + * FYI Deno spoofs process.versions.node, see https://deno.land/std@0.177.0/node/process.ts?s=versions + */ + const isDeno = + typeof Deno !== "undefined" && typeof Deno.version !== "undefined" && typeof Deno.version.deno !== "undefined"; + if (isDeno) { + return { + type: "deno", + version: Deno.version.deno, + }; + } + + /** + * A constant that indicates whether the environment the code is running is Bun.sh. + */ + const isBun = typeof Bun !== "undefined" && typeof Bun.version !== "undefined"; + if (isBun) { + return { + type: "bun", + version: Bun.version, + }; + } + + /** + * A constant that indicates whether the environment the code is running is in React-Native. + * This check should come before Node.js detection since React Native may have a process polyfill. + * https://github.com/facebook/react-native/blob/main/packages/react-native/Libraries/Core/setUpNavigator.js + */ + const isReactNative = typeof navigator !== "undefined" && navigator?.product === "ReactNative"; + if (isReactNative) { + return { + type: "react-native", + }; + } + + /** + * A constant that indicates whether the environment the code is running is Node.JS. + */ + const isNode = + typeof process !== "undefined" && + "version" in process && + !!process.version && + "versions" in process && + !!process.versions?.node; + if (isNode) { + return { + type: "node", + version: process.versions.node, + parsedVersion: Number(process.versions.node.split(".")[0]), + }; + } + + return { + type: "unknown", + }; +} diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/url/encodePathParam.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/url/encodePathParam.ts new file mode 100644 index 000000000000..19b901244218 --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/url/encodePathParam.ts @@ -0,0 +1,18 @@ +export function encodePathParam(param: unknown): string { + if (param === null) { + return "null"; + } + const typeofParam = typeof param; + switch (typeofParam) { + case "undefined": + return "undefined"; + case "string": + case "number": + case "boolean": + break; + default: + param = String(param); + break; + } + return encodeURIComponent(param as string | number | boolean); +} diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/url/index.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/url/index.ts new file mode 100644 index 000000000000..f2e0fa2d2221 --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/url/index.ts @@ -0,0 +1,3 @@ +export { encodePathParam } from "./encodePathParam.js"; +export { join } from "./join.js"; +export { toQueryString } from "./qs.js"; diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/url/join.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/url/join.ts new file mode 100644 index 000000000000..7ca7daef094d --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/url/join.ts @@ -0,0 +1,79 @@ +export function join(base: string, ...segments: string[]): string { + if (!base) { + return ""; + } + + if (segments.length === 0) { + return base; + } + + if (base.includes("://")) { + let url: URL; + try { + url = new URL(base); + } catch { + return joinPath(base, ...segments); + } + + const lastSegment = segments[segments.length - 1]; + const shouldPreserveTrailingSlash = lastSegment?.endsWith("/"); + + for (const segment of segments) { + const cleanSegment = trimSlashes(segment); + if (cleanSegment) { + url.pathname = joinPathSegments(url.pathname, cleanSegment); + } + } + + if (shouldPreserveTrailingSlash && !url.pathname.endsWith("/")) { + url.pathname += "/"; + } + + return url.toString(); + } + + return joinPath(base, ...segments); +} + +function joinPath(base: string, ...segments: string[]): string { + if (segments.length === 0) { + return base; + } + + let result = base; + + const lastSegment = segments[segments.length - 1]; + const shouldPreserveTrailingSlash = lastSegment?.endsWith("/"); + + for (const segment of segments) { + const cleanSegment = trimSlashes(segment); + if (cleanSegment) { + result = joinPathSegments(result, cleanSegment); + } + } + + if (shouldPreserveTrailingSlash && !result.endsWith("/")) { + result += "/"; + } + + return result; +} + +function joinPathSegments(left: string, right: string): string { + if (left.endsWith("/")) { + return left + right; + } + return `${left}/${right}`; +} + +function trimSlashes(str: string): string { + if (!str) return str; + + let start = 0; + let end = str.length; + + if (str.startsWith("/")) start = 1; + if (str.endsWith("/")) end = str.length - 1; + + return start === 0 && end === str.length ? str : str.slice(start, end); +} diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/url/qs.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/url/qs.ts new file mode 100644 index 000000000000..13e89be9d9a6 --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/src/core/url/qs.ts @@ -0,0 +1,74 @@ +interface QueryStringOptions { + arrayFormat?: "indices" | "repeat"; + encode?: boolean; +} + +const defaultQsOptions: Required = { + arrayFormat: "indices", + encode: true, +} as const; + +function encodeValue(value: unknown, shouldEncode: boolean): string { + if (value === undefined) { + return ""; + } + if (value === null) { + return ""; + } + const stringValue = String(value); + return shouldEncode ? encodeURIComponent(stringValue) : stringValue; +} + +function stringifyObject(obj: Record, prefix = "", options: Required): string[] { + const parts: string[] = []; + + for (const [key, value] of Object.entries(obj)) { + const fullKey = prefix ? `${prefix}[${key}]` : key; + + if (value === undefined) { + continue; + } + + if (Array.isArray(value)) { + if (value.length === 0) { + continue; + } + for (let i = 0; i < value.length; i++) { + const item = value[i]; + if (item === undefined) { + continue; + } + if (typeof item === "object" && !Array.isArray(item) && item !== null) { + const arrayKey = options.arrayFormat === "indices" ? `${fullKey}[${i}]` : fullKey; + parts.push(...stringifyObject(item as Record, arrayKey, options)); + } else { + const arrayKey = options.arrayFormat === "indices" ? `${fullKey}[${i}]` : fullKey; + const encodedKey = options.encode ? encodeURIComponent(arrayKey) : arrayKey; + parts.push(`${encodedKey}=${encodeValue(item, options.encode)}`); + } + } + } else if (typeof value === "object" && value !== null) { + if (Object.keys(value as Record).length === 0) { + continue; + } + parts.push(...stringifyObject(value as Record, fullKey, options)); + } else { + const encodedKey = options.encode ? encodeURIComponent(fullKey) : fullKey; + parts.push(`${encodedKey}=${encodeValue(value, options.encode)}`); + } + } + + return parts; +} + +export function toQueryString(obj: unknown, options?: QueryStringOptions): string { + if (obj == null || typeof obj !== "object") { + return ""; + } + + const parts = stringifyObject(obj as Record, "", { + ...defaultQsOptions, + ...options, + }); + return parts.join("&"); +} diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/src/errors/SeedAnyAuthError.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/src/errors/SeedAnyAuthError.ts new file mode 100644 index 000000000000..027577510e9d --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/src/errors/SeedAnyAuthError.ts @@ -0,0 +1,58 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as core from "../core/index.js"; +import { toJson } from "../core/json.js"; + +export class SeedAnyAuthError extends Error { + public readonly statusCode?: number; + public readonly body?: unknown; + public readonly rawResponse?: core.RawResponse; + + constructor({ + message, + statusCode, + body, + rawResponse, + }: { + message?: string; + statusCode?: number; + body?: unknown; + rawResponse?: core.RawResponse; + }) { + super(buildMessage({ message, statusCode, body })); + Object.setPrototypeOf(this, new.target.prototype); + if (Error.captureStackTrace) { + Error.captureStackTrace(this, this.constructor); + } + + this.name = this.constructor.name; + this.statusCode = statusCode; + this.body = body; + this.rawResponse = rawResponse; + } +} + +function buildMessage({ + message, + statusCode, + body, +}: { + message: string | undefined; + statusCode: number | undefined; + body: unknown | undefined; +}): string { + const lines: string[] = []; + if (message != null) { + lines.push(message); + } + + if (statusCode != null) { + lines.push(`Status code: ${statusCode.toString()}`); + } + + if (body != null) { + lines.push(`Body: ${toJson(body, undefined, 2)}`); + } + + return lines.join("\n"); +} diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/src/errors/SeedAnyAuthTimeoutError.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/src/errors/SeedAnyAuthTimeoutError.ts new file mode 100644 index 000000000000..2c4bfa3ca555 --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/src/errors/SeedAnyAuthTimeoutError.ts @@ -0,0 +1,13 @@ +// This file was auto-generated by Fern from our API Definition. + +export class SeedAnyAuthTimeoutError extends Error { + constructor(message: string) { + super(message); + Object.setPrototypeOf(this, new.target.prototype); + if (Error.captureStackTrace) { + Error.captureStackTrace(this, this.constructor); + } + + this.name = this.constructor.name; + } +} diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/src/errors/handleNonStatusCodeError.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/src/errors/handleNonStatusCodeError.ts new file mode 100644 index 000000000000..b50bba2dea79 --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/src/errors/handleNonStatusCodeError.ts @@ -0,0 +1,37 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as core from "../core/index.js"; +import * as errors from "./index.js"; + +export function handleNonStatusCodeError( + error: core.Fetcher.Error, + rawResponse: core.RawResponse, + method: string, + path: string, +): never { + switch (error.reason) { + case "non-json": + throw new errors.SeedAnyAuthError({ + statusCode: error.statusCode, + body: error.rawBody, + rawResponse: rawResponse, + }); + case "body-is-null": + throw new errors.SeedAnyAuthError({ + statusCode: error.statusCode, + rawResponse: rawResponse, + }); + case "timeout": + throw new errors.SeedAnyAuthTimeoutError(`Timeout exceeded when calling ${method} ${path}.`); + case "unknown": + throw new errors.SeedAnyAuthError({ + message: error.errorMessage, + rawResponse: rawResponse, + }); + default: + throw new errors.SeedAnyAuthError({ + message: "Unknown error", + rawResponse: rawResponse, + }); + } +} diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/src/errors/index.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/src/errors/index.ts new file mode 100644 index 000000000000..8eaf0a37926a --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/src/errors/index.ts @@ -0,0 +1,2 @@ +export { SeedAnyAuthError } from "./SeedAnyAuthError.js"; +export { SeedAnyAuthTimeoutError } from "./SeedAnyAuthTimeoutError.js"; diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/src/exports.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/src/exports.ts new file mode 100644 index 000000000000..7b70ee14fc02 --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/src/exports.ts @@ -0,0 +1 @@ +export * from "./core/exports.js"; diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/src/index.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/src/index.ts new file mode 100644 index 000000000000..740e38c87a94 --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/src/index.ts @@ -0,0 +1,5 @@ +export * as SeedAnyAuth from "./api/index.js"; +export type { BaseClientOptions, BaseRequestOptions } from "./BaseClient.js"; +export { SeedAnyAuthClient } from "./Client.js"; +export { SeedAnyAuthError, SeedAnyAuthTimeoutError } from "./errors/index.js"; +export * from "./exports.js"; diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/src/version.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/src/version.ts new file mode 100644 index 000000000000..b643a3e3ea27 --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/src/version.ts @@ -0,0 +1 @@ +export const SDK_VERSION = "0.0.1"; diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/tests/custom.test.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/tests/custom.test.ts new file mode 100644 index 000000000000..7f5e031c8396 --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/tests/custom.test.ts @@ -0,0 +1,13 @@ +/** + * This is a custom test file, if you wish to add more tests + * to your SDK. + * Be sure to mark this file in `.fernignore`. + * + * If you include example requests/responses in your fern definition, + * you will have tests automatically generated for you. + */ +describe("test", () => { + it("default", () => { + expect(true).toBe(true); + }); +}); diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/tests/mock-server/MockServer.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/tests/mock-server/MockServer.ts new file mode 100644 index 000000000000..954872157d52 --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/tests/mock-server/MockServer.ts @@ -0,0 +1,29 @@ +import type { RequestHandlerOptions } from "msw"; +import type { SetupServer } from "msw/node"; + +import { mockEndpointBuilder } from "./mockEndpointBuilder"; + +export interface MockServerOptions { + baseUrl: string; + server: SetupServer; +} + +export class MockServer { + private readonly server: SetupServer; + public readonly baseUrl: string; + + constructor({ baseUrl, server }: MockServerOptions) { + this.baseUrl = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl; + this.server = server; + } + + public mockEndpoint(options?: RequestHandlerOptions): ReturnType { + const builder = mockEndpointBuilder({ + once: options?.once ?? true, + onBuild: (handler) => { + this.server.use(handler); + }, + }).baseUrl(this.baseUrl); + return builder; + } +} diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/tests/mock-server/MockServerPool.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/tests/mock-server/MockServerPool.ts new file mode 100644 index 000000000000..e1a90f7fb2e3 --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/tests/mock-server/MockServerPool.ts @@ -0,0 +1,106 @@ +import { setupServer } from "msw/node"; + +import { fromJson, toJson } from "../../src/core/json"; +import { MockServer } from "./MockServer"; +import { randomBaseUrl } from "./randomBaseUrl"; + +const mswServer = setupServer(); +interface MockServerOptions { + baseUrl?: string; +} + +async function formatHttpRequest(request: Request, id?: string): Promise { + try { + const clone = request.clone(); + const headers = [...clone.headers.entries()].map(([k, v]) => `${k}: ${v}`).join("\n"); + + let body = ""; + try { + const contentType = clone.headers.get("content-type"); + if (contentType?.includes("application/json")) { + body = toJson(fromJson(await clone.text()), undefined, 2); + } else if (clone.body) { + body = await clone.text(); + } + } catch (_e) { + body = "(unable to parse body)"; + } + + const title = id ? `### Request ${id} ###\n` : ""; + const firstLine = `${title}${request.method} ${request.url.toString()} HTTP/1.1`; + + return `\n${firstLine}\n${headers}\n\n${body || "(no body)"}\n`; + } catch (e) { + return `Error formatting request: ${e}`; + } +} + +async function formatHttpResponse(response: Response, id?: string): Promise { + try { + const clone = response.clone(); + const headers = [...clone.headers.entries()].map(([k, v]) => `${k}: ${v}`).join("\n"); + + let body = ""; + try { + const contentType = clone.headers.get("content-type"); + if (contentType?.includes("application/json")) { + body = toJson(fromJson(await clone.text()), undefined, 2); + } else if (clone.body) { + body = await clone.text(); + } + } catch (_e) { + body = "(unable to parse body)"; + } + + const title = id ? `### Response for ${id} ###\n` : ""; + const firstLine = `${title}HTTP/1.1 ${response.status} ${response.statusText}`; + + return `\n${firstLine}\n${headers}\n\n${body || "(no body)"}\n`; + } catch (e) { + return `Error formatting response: ${e}`; + } +} + +class MockServerPool { + private servers: MockServer[] = []; + + public createServer(options?: Partial): MockServer { + const baseUrl = options?.baseUrl || randomBaseUrl(); + const server = new MockServer({ baseUrl, server: mswServer }); + this.servers.push(server); + return server; + } + + public getServers(): MockServer[] { + return [...this.servers]; + } + + public listen(): void { + const onUnhandledRequest = process.env.LOG_LEVEL === "debug" ? "warn" : "bypass"; + mswServer.listen({ onUnhandledRequest }); + + if (process.env.LOG_LEVEL === "debug") { + mswServer.events.on("request:start", async ({ request, requestId }) => { + const formattedRequest = await formatHttpRequest(request, requestId); + console.debug(`request:start\n${formattedRequest}`); + }); + + mswServer.events.on("request:unhandled", async ({ request, requestId }) => { + const formattedRequest = await formatHttpRequest(request, requestId); + console.debug(`request:unhandled\n${formattedRequest}`); + }); + + mswServer.events.on("response:mocked", async ({ request, response, requestId }) => { + const formattedResponse = await formatHttpResponse(response, requestId); + console.debug(`response:mocked\n${formattedResponse}`); + }); + } + } + + public close(): void { + this.servers = []; + mswServer.close(); + } +} + +export const mockServerPool = new MockServerPool(); diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/tests/mock-server/mockEndpointBuilder.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/tests/mock-server/mockEndpointBuilder.ts new file mode 100644 index 000000000000..1b0e51079e6b --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/tests/mock-server/mockEndpointBuilder.ts @@ -0,0 +1,227 @@ +import { type DefaultBodyType, type HttpHandler, HttpResponse, type HttpResponseResolver, http } from "msw"; + +import { url } from "../../src/core"; +import { toJson } from "../../src/core/json"; +import { withFormUrlEncoded } from "./withFormUrlEncoded"; +import { withHeaders } from "./withHeaders"; +import { withJson } from "./withJson"; + +type HttpMethod = "all" | "get" | "post" | "put" | "delete" | "patch" | "options" | "head"; + +interface MethodStage { + baseUrl(baseUrl: string): MethodStage; + all(path: string): RequestHeadersStage; + get(path: string): RequestHeadersStage; + post(path: string): RequestHeadersStage; + put(path: string): RequestHeadersStage; + delete(path: string): RequestHeadersStage; + patch(path: string): RequestHeadersStage; + options(path: string): RequestHeadersStage; + head(path: string): RequestHeadersStage; +} + +interface RequestHeadersStage extends RequestBodyStage, ResponseStage { + header(name: string, value: string): RequestHeadersStage; + headers(headers: Record): RequestBodyStage; +} + +interface RequestBodyStage extends ResponseStage { + jsonBody(body: unknown): ResponseStage; + formUrlEncodedBody(body: unknown): ResponseStage; +} + +interface ResponseStage { + respondWith(): ResponseStatusStage; +} +interface ResponseStatusStage { + statusCode(statusCode: number): ResponseHeaderStage; +} + +interface ResponseHeaderStage extends ResponseBodyStage, BuildStage { + header(name: string, value: string): ResponseHeaderStage; + headers(headers: Record): ResponseHeaderStage; +} + +interface ResponseBodyStage { + jsonBody(body: unknown): BuildStage; +} + +interface BuildStage { + build(): HttpHandler; +} + +export interface HttpHandlerBuilderOptions { + onBuild?: (handler: HttpHandler) => void; + once?: boolean; +} + +class RequestBuilder implements MethodStage, RequestHeadersStage, RequestBodyStage, ResponseStage { + private method: HttpMethod = "get"; + private _baseUrl: string = ""; + private path: string = "/"; + private readonly predicates: ((resolver: HttpResponseResolver) => HttpResponseResolver)[] = []; + private readonly handlerOptions?: HttpHandlerBuilderOptions; + + constructor(options?: HttpHandlerBuilderOptions) { + this.handlerOptions = options; + } + + baseUrl(baseUrl: string): MethodStage { + this._baseUrl = baseUrl; + return this; + } + + all(path: string): RequestHeadersStage { + this.method = "all"; + this.path = path; + return this; + } + + get(path: string): RequestHeadersStage { + this.method = "get"; + this.path = path; + return this; + } + + post(path: string): RequestHeadersStage { + this.method = "post"; + this.path = path; + return this; + } + + put(path: string): RequestHeadersStage { + this.method = "put"; + this.path = path; + return this; + } + + delete(path: string): RequestHeadersStage { + this.method = "delete"; + this.path = path; + return this; + } + + patch(path: string): RequestHeadersStage { + this.method = "patch"; + this.path = path; + return this; + } + + options(path: string): RequestHeadersStage { + this.method = "options"; + this.path = path; + return this; + } + + head(path: string): RequestHeadersStage { + this.method = "head"; + this.path = path; + return this; + } + + header(name: string, value: string): RequestHeadersStage { + this.predicates.push((resolver) => withHeaders({ [name]: value }, resolver)); + return this; + } + + headers(headers: Record): RequestBodyStage { + this.predicates.push((resolver) => withHeaders(headers, resolver)); + return this; + } + + jsonBody(body: unknown): ResponseStage { + if (body === undefined) { + throw new Error("Undefined is not valid JSON. Do not call jsonBody if you want an empty body."); + } + this.predicates.push((resolver) => withJson(body, resolver)); + return this; + } + + formUrlEncodedBody(body: unknown): ResponseStage { + if (body === undefined) { + throw new Error( + "Undefined is not valid for form-urlencoded. Do not call formUrlEncodedBody if you want an empty body.", + ); + } + this.predicates.push((resolver) => withFormUrlEncoded(body, resolver)); + return this; + } + + respondWith(): ResponseStatusStage { + return new ResponseBuilder(this.method, this.buildUrl(), this.predicates, this.handlerOptions); + } + + private buildUrl(): string { + return url.join(this._baseUrl, this.path); + } +} + +class ResponseBuilder implements ResponseStatusStage, ResponseHeaderStage, ResponseBodyStage, BuildStage { + private readonly method: HttpMethod; + private readonly url: string; + private readonly requestPredicates: ((resolver: HttpResponseResolver) => HttpResponseResolver)[]; + private readonly handlerOptions?: HttpHandlerBuilderOptions; + + private responseStatusCode: number = 200; + private responseHeaders: Record = {}; + private responseBody: DefaultBodyType = undefined; + + constructor( + method: HttpMethod, + url: string, + requestPredicates: ((resolver: HttpResponseResolver) => HttpResponseResolver)[], + options?: HttpHandlerBuilderOptions, + ) { + this.method = method; + this.url = url; + this.requestPredicates = requestPredicates; + this.handlerOptions = options; + } + + public statusCode(code: number): ResponseHeaderStage { + this.responseStatusCode = code; + return this; + } + + public header(name: string, value: string): ResponseHeaderStage { + this.responseHeaders[name] = value; + return this; + } + + public headers(headers: Record): ResponseHeaderStage { + this.responseHeaders = { ...this.responseHeaders, ...headers }; + return this; + } + + public jsonBody(body: unknown): BuildStage { + if (body === undefined) { + throw new Error("Undefined is not valid JSON. Do not call jsonBody if you expect an empty body."); + } + this.responseBody = toJson(body); + return this; + } + + public build(): HttpHandler { + const responseResolver: HttpResponseResolver = () => { + const response = new HttpResponse(this.responseBody, { + status: this.responseStatusCode, + headers: this.responseHeaders, + }); + // if no Content-Type header is set, delete the default text content type that is set + if (Object.keys(this.responseHeaders).some((key) => key.toLowerCase() === "content-type") === false) { + response.headers.delete("Content-Type"); + } + return response; + }; + + const finalResolver = this.requestPredicates.reduceRight((acc, predicate) => predicate(acc), responseResolver); + + const handler = http[this.method](this.url, finalResolver, this.handlerOptions); + this.handlerOptions?.onBuild?.(handler); + return handler; + } +} + +export function mockEndpointBuilder(options?: HttpHandlerBuilderOptions): MethodStage { + return new RequestBuilder(options); +} diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/tests/mock-server/randomBaseUrl.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/tests/mock-server/randomBaseUrl.ts new file mode 100644 index 000000000000..031aa6408aca --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/tests/mock-server/randomBaseUrl.ts @@ -0,0 +1,4 @@ +export function randomBaseUrl(): string { + const randomString = Math.random().toString(36).substring(2, 15); + return `http://${randomString}.localhost`; +} diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/tests/mock-server/setup.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/tests/mock-server/setup.ts new file mode 100644 index 000000000000..aeb3a95af7dc --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/tests/mock-server/setup.ts @@ -0,0 +1,10 @@ +import { afterAll, beforeAll } from "vitest"; + +import { mockServerPool } from "./MockServerPool"; + +beforeAll(() => { + mockServerPool.listen(); +}); +afterAll(() => { + mockServerPool.close(); +}); diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/tests/mock-server/withFormUrlEncoded.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/tests/mock-server/withFormUrlEncoded.ts new file mode 100644 index 000000000000..e9e6ff2d9cf1 --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/tests/mock-server/withFormUrlEncoded.ts @@ -0,0 +1,80 @@ +import { type HttpResponseResolver, passthrough } from "msw"; + +import { toJson } from "../../src/core/json"; + +/** + * Creates a request matcher that validates if the request form-urlencoded body exactly matches the expected object + * @param expectedBody - The exact body object to match against + * @param resolver - Response resolver to execute if body matches + */ +export function withFormUrlEncoded(expectedBody: unknown, resolver: HttpResponseResolver): HttpResponseResolver { + return async (args) => { + const { request } = args; + + let clonedRequest: Request; + let bodyText: string | undefined; + let actualBody: Record; + try { + clonedRequest = request.clone(); + bodyText = await clonedRequest.text(); + if (bodyText === "") { + console.error("Request body is empty, expected a form-urlencoded body."); + return passthrough(); + } + const params = new URLSearchParams(bodyText); + actualBody = {}; + for (const [key, value] of params.entries()) { + actualBody[key] = value; + } + } catch (error) { + console.error(`Error processing form-urlencoded request body:\n\tError: ${error}\n\tBody: ${bodyText}`); + return passthrough(); + } + + const mismatches = findMismatches(actualBody, expectedBody); + if (Object.keys(mismatches).length > 0) { + console.error("Form-urlencoded body mismatch:", toJson(mismatches, undefined, 2)); + return passthrough(); + } + + return resolver(args); + }; +} + +function findMismatches(actual: any, expected: any): Record { + const mismatches: Record = {}; + + if (typeof actual !== typeof expected) { + return { value: { actual, expected } }; + } + + if (typeof actual !== "object" || actual === null || expected === null) { + if (actual !== expected) { + return { value: { actual, expected } }; + } + return {}; + } + + const actualKeys = Object.keys(actual); + const expectedKeys = Object.keys(expected); + + const allKeys = new Set([...actualKeys, ...expectedKeys]); + + for (const key of allKeys) { + if (!expectedKeys.includes(key)) { + if (actual[key] === undefined) { + continue; + } + mismatches[key] = { actual: actual[key], expected: undefined }; + } else if (!actualKeys.includes(key)) { + if (expected[key] === undefined) { + continue; + } + mismatches[key] = { actual: undefined, expected: expected[key] }; + } else if (actual[key] !== expected[key]) { + mismatches[key] = { actual: actual[key], expected: expected[key] }; + } + } + + return mismatches; +} diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/tests/mock-server/withHeaders.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/tests/mock-server/withHeaders.ts new file mode 100644 index 000000000000..6599d2b4a92d --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/tests/mock-server/withHeaders.ts @@ -0,0 +1,70 @@ +import { type HttpResponseResolver, passthrough } from "msw"; + +/** + * Creates a request matcher that validates if request headers match specified criteria + * @param expectedHeaders - Headers to match against + * @param resolver - Response resolver to execute if headers match + */ +export function withHeaders( + expectedHeaders: Record boolean)>, + resolver: HttpResponseResolver, +): HttpResponseResolver { + return (args) => { + const { request } = args; + const { headers } = request; + + const mismatches: Record< + string, + { actual: string | null; expected: string | RegExp | ((value: string) => boolean) } + > = {}; + + for (const [key, expectedValue] of Object.entries(expectedHeaders)) { + const actualValue = headers.get(key); + + if (actualValue === null) { + mismatches[key] = { actual: null, expected: expectedValue }; + continue; + } + + if (typeof expectedValue === "function") { + if (!expectedValue(actualValue)) { + mismatches[key] = { actual: actualValue, expected: expectedValue }; + } + } else if (expectedValue instanceof RegExp) { + if (!expectedValue.test(actualValue)) { + mismatches[key] = { actual: actualValue, expected: expectedValue }; + } + } else if (expectedValue !== actualValue) { + mismatches[key] = { actual: actualValue, expected: expectedValue }; + } + } + + if (Object.keys(mismatches).length > 0) { + const formattedMismatches = formatHeaderMismatches(mismatches); + console.error("Header mismatch:", formattedMismatches); + return passthrough(); + } + + return resolver(args); + }; +} + +function formatHeaderMismatches( + mismatches: Record boolean) }>, +): Record { + const formatted: Record = {}; + + for (const [key, { actual, expected }] of Object.entries(mismatches)) { + formatted[key] = { + actual, + expected: + expected instanceof RegExp + ? expected.toString() + : typeof expected === "function" + ? "[Function]" + : expected, + }; + } + + return formatted; +} diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/tests/mock-server/withJson.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/tests/mock-server/withJson.ts new file mode 100644 index 000000000000..b627638b015f --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/tests/mock-server/withJson.ts @@ -0,0 +1,158 @@ +import { type HttpResponseResolver, passthrough } from "msw"; + +import { fromJson, toJson } from "../../src/core/json"; + +/** + * Creates a request matcher that validates if the request JSON body exactly matches the expected object + * @param expectedBody - The exact body object to match against + * @param resolver - Response resolver to execute if body matches + */ +export function withJson(expectedBody: unknown, resolver: HttpResponseResolver): HttpResponseResolver { + return async (args) => { + const { request } = args; + + let clonedRequest: Request; + let bodyText: string | undefined; + let actualBody: unknown; + try { + clonedRequest = request.clone(); + bodyText = await clonedRequest.text(); + if (bodyText === "") { + console.error("Request body is empty, expected a JSON object."); + return passthrough(); + } + actualBody = fromJson(bodyText); + } catch (error) { + console.error(`Error processing request body:\n\tError: ${error}\n\tBody: ${bodyText}`); + return passthrough(); + } + + const mismatches = findMismatches(actualBody, expectedBody); + if (Object.keys(mismatches).filter((key) => !key.startsWith("pagination.")).length > 0) { + console.error("JSON body mismatch:", toJson(mismatches, undefined, 2)); + return passthrough(); + } + + return resolver(args); + }; +} + +function findMismatches(actual: any, expected: any): Record { + const mismatches: Record = {}; + + if (typeof actual !== typeof expected) { + if (areEquivalent(actual, expected)) { + return {}; + } + return { value: { actual, expected } }; + } + + if (typeof actual !== "object" || actual === null || expected === null) { + if (actual !== expected) { + if (areEquivalent(actual, expected)) { + return {}; + } + return { value: { actual, expected } }; + } + return {}; + } + + if (Array.isArray(actual) && Array.isArray(expected)) { + if (actual.length !== expected.length) { + return { length: { actual: actual.length, expected: expected.length } }; + } + + const arrayMismatches: Record = {}; + for (let i = 0; i < actual.length; i++) { + const itemMismatches = findMismatches(actual[i], expected[i]); + if (Object.keys(itemMismatches).length > 0) { + for (const [mismatchKey, mismatchValue] of Object.entries(itemMismatches)) { + arrayMismatches[`[${i}]${mismatchKey === "value" ? "" : `.${mismatchKey}`}`] = mismatchValue; + } + } + } + return arrayMismatches; + } + + const actualKeys = Object.keys(actual); + const expectedKeys = Object.keys(expected); + + const allKeys = new Set([...actualKeys, ...expectedKeys]); + + for (const key of allKeys) { + if (!expectedKeys.includes(key)) { + if (actual[key] === undefined) { + continue; // Skip undefined values in actual + } + mismatches[key] = { actual: actual[key], expected: undefined }; + } else if (!actualKeys.includes(key)) { + if (expected[key] === undefined) { + continue; // Skip undefined values in expected + } + mismatches[key] = { actual: undefined, expected: expected[key] }; + } else if ( + typeof actual[key] === "object" && + actual[key] !== null && + typeof expected[key] === "object" && + expected[key] !== null + ) { + const nestedMismatches = findMismatches(actual[key], expected[key]); + if (Object.keys(nestedMismatches).length > 0) { + for (const [nestedKey, nestedValue] of Object.entries(nestedMismatches)) { + mismatches[`${key}${nestedKey === "value" ? "" : `.${nestedKey}`}`] = nestedValue; + } + } + } else if (actual[key] !== expected[key]) { + if (areEquivalent(actual[key], expected[key])) { + continue; + } + mismatches[key] = { actual: actual[key], expected: expected[key] }; + } + } + + return mismatches; +} + +function areEquivalent(actual: unknown, expected: unknown): boolean { + if (actual === expected) { + return true; + } + if (isEquivalentBigInt(actual, expected)) { + return true; + } + if (isEquivalentDatetime(actual, expected)) { + return true; + } + return false; +} + +function isEquivalentBigInt(actual: unknown, expected: unknown) { + if (typeof actual === "number") { + actual = BigInt(actual); + } + if (typeof expected === "number") { + expected = BigInt(expected); + } + if (typeof actual === "bigint" && typeof expected === "bigint") { + return actual === expected; + } + return false; +} + +function isEquivalentDatetime(str1: unknown, str2: unknown): boolean { + if (typeof str1 !== "string" || typeof str2 !== "string") { + return false; + } + const isoDatePattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/; + if (!isoDatePattern.test(str1) || !isoDatePattern.test(str2)) { + return false; + } + + try { + const date1 = new Date(str1).getTime(); + const date2 = new Date(str2).getTime(); + return date1 === date2; + } catch { + return false; + } +} diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/tests/setup.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/tests/setup.ts new file mode 100644 index 000000000000..a5651f81ba10 --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/tests/setup.ts @@ -0,0 +1,80 @@ +import { expect } from "vitest"; + +interface CustomMatchers { + toContainHeaders(expectedHeaders: Record): R; +} + +declare module "vitest" { + interface Assertion extends CustomMatchers {} + interface AsymmetricMatchersContaining extends CustomMatchers {} +} + +expect.extend({ + toContainHeaders(actual: unknown, expectedHeaders: Record) { + const isHeaders = actual instanceof Headers; + const isPlainObject = typeof actual === "object" && actual !== null && !Array.isArray(actual); + + if (!isHeaders && !isPlainObject) { + throw new TypeError("Received value must be an instance of Headers or a plain object!"); + } + + if (typeof expectedHeaders !== "object" || expectedHeaders === null || Array.isArray(expectedHeaders)) { + throw new TypeError("Expected headers must be a plain object!"); + } + + const missingHeaders: string[] = []; + const mismatchedHeaders: Array<{ key: string; expected: string; actual: string | null }> = []; + + for (const [key, value] of Object.entries(expectedHeaders)) { + let actualValue: string | null = null; + + if (isHeaders) { + // Headers.get() is already case-insensitive + actualValue = (actual as Headers).get(key); + } else { + // For plain objects, do case-insensitive lookup + const actualObj = actual as Record; + const lowerKey = key.toLowerCase(); + const foundKey = Object.keys(actualObj).find((k) => k.toLowerCase() === lowerKey); + actualValue = foundKey ? actualObj[foundKey] : null; + } + + if (actualValue === null || actualValue === undefined) { + missingHeaders.push(key); + } else if (actualValue !== value) { + mismatchedHeaders.push({ key, expected: value, actual: actualValue }); + } + } + + const pass = missingHeaders.length === 0 && mismatchedHeaders.length === 0; + + const actualType = isHeaders ? "Headers" : "object"; + + if (pass) { + return { + message: () => `expected ${actualType} not to contain ${this.utils.printExpected(expectedHeaders)}`, + pass: true, + }; + } else { + const messages: string[] = []; + + if (missingHeaders.length > 0) { + messages.push(`Missing headers: ${this.utils.printExpected(missingHeaders.join(", "))}`); + } + + if (mismatchedHeaders.length > 0) { + const mismatches = mismatchedHeaders.map( + ({ key, expected, actual }) => + `${key}: expected ${this.utils.printExpected(expected)} but got ${this.utils.printReceived(actual)}`, + ); + messages.push(mismatches.join("\n")); + } + + return { + message: () => + `expected ${actualType} to contain ${this.utils.printExpected(expectedHeaders)}\n\n${messages.join("\n")}`, + pass: false, + }; + } + }, +}); diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/tests/tsconfig.json b/seed/ts-sdk/any-auth/discriminated-union-auth/tests/tsconfig.json new file mode 100644 index 000000000000..a477df47920c --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/tests/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "outDir": null, + "rootDir": "..", + "baseUrl": "..", + "types": ["vitest/globals"] + }, + "include": ["../src", "../tests"], + "exclude": [] +} diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/tests/unit/auth/BasicAuth.test.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/tests/unit/auth/BasicAuth.test.ts new file mode 100644 index 000000000000..9b5123364c47 --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/tests/unit/auth/BasicAuth.test.ts @@ -0,0 +1,92 @@ +import { BasicAuth } from "../../../src/core/auth/BasicAuth"; + +describe("BasicAuth", () => { + interface ToHeaderTestCase { + description: string; + input: { username: string; password: string }; + expected: string; + } + + interface FromHeaderTestCase { + description: string; + input: string; + expected: { username: string; password: string }; + } + + interface ErrorTestCase { + description: string; + input: string; + expectedError: string; + } + + describe("toAuthorizationHeader", () => { + const toHeaderTests: ToHeaderTestCase[] = [ + { + description: "correctly converts to header", + input: { username: "username", password: "password" }, + expected: "Basic dXNlcm5hbWU6cGFzc3dvcmQ=", + }, + ]; + + toHeaderTests.forEach(({ description, input, expected }) => { + it(description, () => { + expect(BasicAuth.toAuthorizationHeader(input)).toBe(expected); + }); + }); + }); + + describe("fromAuthorizationHeader", () => { + const fromHeaderTests: FromHeaderTestCase[] = [ + { + description: "correctly parses header", + input: "Basic dXNlcm5hbWU6cGFzc3dvcmQ=", + expected: { username: "username", password: "password" }, + }, + { + description: "handles password with colons", + input: "Basic dXNlcjpwYXNzOndvcmQ=", + expected: { username: "user", password: "pass:word" }, + }, + { + description: "handles empty username and password (just colon)", + input: "Basic Og==", + expected: { username: "", password: "" }, + }, + { + description: "handles empty username", + input: "Basic OnBhc3N3b3Jk", + expected: { username: "", password: "password" }, + }, + { + description: "handles empty password", + input: "Basic dXNlcm5hbWU6", + expected: { username: "username", password: "" }, + }, + ]; + + fromHeaderTests.forEach(({ description, input, expected }) => { + it(description, () => { + expect(BasicAuth.fromAuthorizationHeader(input)).toEqual(expected); + }); + }); + + const errorTests: ErrorTestCase[] = [ + { + description: "throws error for completely empty credentials", + input: "Basic ", + expectedError: "Invalid basic auth", + }, + { + description: "throws error for credentials without colon", + input: "Basic dXNlcm5hbWU=", + expectedError: "Invalid basic auth", + }, + ]; + + errorTests.forEach(({ description, input, expectedError }) => { + it(description, () => { + expect(() => BasicAuth.fromAuthorizationHeader(input)).toThrow(expectedError); + }); + }); + }); +}); diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/tests/unit/auth/BearerToken.test.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/tests/unit/auth/BearerToken.test.ts new file mode 100644 index 000000000000..7757b87cb97e --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/tests/unit/auth/BearerToken.test.ts @@ -0,0 +1,14 @@ +import { BearerToken } from "../../../src/core/auth/BearerToken"; + +describe("BearerToken", () => { + describe("toAuthorizationHeader", () => { + it("correctly converts to header", () => { + expect(BearerToken.toAuthorizationHeader("my-token")).toBe("Bearer my-token"); + }); + }); + describe("fromAuthorizationHeader", () => { + it("correctly parses header", () => { + expect(BearerToken.fromAuthorizationHeader("Bearer my-token")).toBe("my-token"); + }); + }); +}); diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/tests/unit/base64.test.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/tests/unit/base64.test.ts new file mode 100644 index 000000000000..939594ca277b --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/tests/unit/base64.test.ts @@ -0,0 +1,53 @@ +import { base64Decode, base64Encode } from "../../src/core/base64"; + +describe("base64", () => { + describe("base64Encode", () => { + it("should encode ASCII strings", () => { + expect(base64Encode("hello")).toBe("aGVsbG8="); + expect(base64Encode("")).toBe(""); + }); + + it("should encode UTF-8 strings", () => { + expect(base64Encode("café")).toBe("Y2Fmw6k="); + expect(base64Encode("🎉")).toBe("8J+OiQ=="); + }); + + it("should handle basic auth credentials", () => { + expect(base64Encode("username:password")).toBe("dXNlcm5hbWU6cGFzc3dvcmQ="); + }); + }); + + describe("base64Decode", () => { + it("should decode ASCII strings", () => { + expect(base64Decode("aGVsbG8=")).toBe("hello"); + expect(base64Decode("")).toBe(""); + }); + + it("should decode UTF-8 strings", () => { + expect(base64Decode("Y2Fmw6k=")).toBe("café"); + expect(base64Decode("8J+OiQ==")).toBe("🎉"); + }); + + it("should handle basic auth credentials", () => { + expect(base64Decode("dXNlcm5hbWU6cGFzc3dvcmQ=")).toBe("username:password"); + }); + }); + + describe("round-trip encoding", () => { + const testStrings = [ + "hello world", + "test@example.com", + "café", + "username:password", + "user@domain.com:super$ecret123!", + ]; + + testStrings.forEach((testString) => { + it(`should round-trip encode/decode: "${testString}"`, () => { + const encoded = base64Encode(testString); + const decoded = base64Decode(encoded); + expect(decoded).toBe(testString); + }); + }); + }); +}); diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/tests/unit/fetcher/Fetcher.test.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/tests/unit/fetcher/Fetcher.test.ts new file mode 100644 index 000000000000..60df2b5e4824 --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/tests/unit/fetcher/Fetcher.test.ts @@ -0,0 +1,261 @@ +import fs from "fs"; +import { join } from "path"; +import stream from "stream"; +import type { BinaryResponse } from "../../../src/core"; +import { type Fetcher, fetcherImpl } from "../../../src/core/fetcher/Fetcher"; + +describe("Test fetcherImpl", () => { + it("should handle successful request", async () => { + const mockArgs: Fetcher.Args = { + url: "https://httpbin.org/post", + method: "POST", + headers: { "X-Test": "x-test-header" }, + body: { data: "test" }, + contentType: "application/json", + requestType: "json", + maxRetries: 0, + responseType: "json", + }; + + global.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ data: "test" }), { + status: 200, + statusText: "OK", + }), + ); + + const result = await fetcherImpl(mockArgs); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.body).toEqual({ data: "test" }); + } + + expect(global.fetch).toHaveBeenCalledWith( + "https://httpbin.org/post", + expect.objectContaining({ + method: "POST", + headers: expect.toContainHeaders({ "X-Test": "x-test-header" }), + body: JSON.stringify({ data: "test" }), + }), + ); + }); + + it("should send octet stream", async () => { + const url = "https://httpbin.org/post/file"; + const mockArgs: Fetcher.Args = { + url, + method: "POST", + headers: { "X-Test": "x-test-header" }, + contentType: "application/octet-stream", + requestType: "bytes", + maxRetries: 0, + responseType: "json", + body: fs.createReadStream(join(__dirname, "test-file.txt")), + }; + + global.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ data: "test" }), { + status: 200, + statusText: "OK", + }), + ); + + const result = await fetcherImpl(mockArgs); + + expect(global.fetch).toHaveBeenCalledWith( + url, + expect.objectContaining({ + method: "POST", + headers: expect.toContainHeaders({ "X-Test": "x-test-header" }), + body: expect.any(fs.ReadStream), + }), + ); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.body).toEqual({ data: "test" }); + } + }); + + it("should receive file as stream", async () => { + const url = "https://httpbin.org/post/file"; + const mockArgs: Fetcher.Args = { + url, + method: "GET", + headers: { "X-Test": "x-test-header" }, + maxRetries: 0, + responseType: "binary-response", + }; + + global.fetch = vi.fn().mockResolvedValue( + new Response( + stream.Readable.toWeb(fs.createReadStream(join(__dirname, "test-file.txt"))) as ReadableStream, + { + status: 200, + statusText: "OK", + }, + ), + ); + + const result = await fetcherImpl(mockArgs); + + expect(global.fetch).toHaveBeenCalledWith( + url, + expect.objectContaining({ + method: "GET", + headers: expect.toContainHeaders({ "X-Test": "x-test-header" }), + }), + ); + expect(result.ok).toBe(true); + if (result.ok) { + const body = result.body as BinaryResponse; + expect(body).toBeDefined(); + expect(body.bodyUsed).toBe(false); + expect(typeof body.stream).toBe("function"); + const stream = body.stream(); + expect(stream).toBeInstanceOf(ReadableStream); + const reader = stream.getReader(); + const { value } = await reader.read(); + const decoder = new TextDecoder(); + const streamContent = decoder.decode(value); + expect(streamContent).toBe("This is a test file!\n"); + expect(body.bodyUsed).toBe(true); + } + }); + + it("should receive file as blob", async () => { + const url = "https://httpbin.org/post/file"; + const mockArgs: Fetcher.Args = { + url, + method: "GET", + headers: { "X-Test": "x-test-header" }, + maxRetries: 0, + responseType: "binary-response", + }; + + global.fetch = vi.fn().mockResolvedValue( + new Response( + stream.Readable.toWeb(fs.createReadStream(join(__dirname, "test-file.txt"))) as ReadableStream, + { + status: 200, + statusText: "OK", + }, + ), + ); + + const result = await fetcherImpl(mockArgs); + + expect(global.fetch).toHaveBeenCalledWith( + url, + expect.objectContaining({ + method: "GET", + headers: expect.toContainHeaders({ "X-Test": "x-test-header" }), + }), + ); + expect(result.ok).toBe(true); + if (result.ok) { + const body = result.body as BinaryResponse; + expect(body).toBeDefined(); + expect(body.bodyUsed).toBe(false); + expect(typeof body.blob).toBe("function"); + const blob = await body.blob(); + expect(blob).toBeInstanceOf(Blob); + const reader = blob.stream().getReader(); + const { value } = await reader.read(); + const decoder = new TextDecoder(); + const streamContent = decoder.decode(value); + expect(streamContent).toBe("This is a test file!\n"); + expect(body.bodyUsed).toBe(true); + } + }); + + it("should receive file as arraybuffer", async () => { + const url = "https://httpbin.org/post/file"; + const mockArgs: Fetcher.Args = { + url, + method: "GET", + headers: { "X-Test": "x-test-header" }, + maxRetries: 0, + responseType: "binary-response", + }; + + global.fetch = vi.fn().mockResolvedValue( + new Response( + stream.Readable.toWeb(fs.createReadStream(join(__dirname, "test-file.txt"))) as ReadableStream, + { + status: 200, + statusText: "OK", + }, + ), + ); + + const result = await fetcherImpl(mockArgs); + + expect(global.fetch).toHaveBeenCalledWith( + url, + expect.objectContaining({ + method: "GET", + headers: expect.toContainHeaders({ "X-Test": "x-test-header" }), + }), + ); + expect(result.ok).toBe(true); + if (result.ok) { + const body = result.body as BinaryResponse; + expect(body).toBeDefined(); + expect(body.bodyUsed).toBe(false); + expect(typeof body.arrayBuffer).toBe("function"); + const arrayBuffer = await body.arrayBuffer(); + expect(arrayBuffer).toBeInstanceOf(ArrayBuffer); + const decoder = new TextDecoder(); + const streamContent = decoder.decode(new Uint8Array(arrayBuffer)); + expect(streamContent).toBe("This is a test file!\n"); + expect(body.bodyUsed).toBe(true); + } + }); + + it("should receive file as bytes", async () => { + const url = "https://httpbin.org/post/file"; + const mockArgs: Fetcher.Args = { + url, + method: "GET", + headers: { "X-Test": "x-test-header" }, + maxRetries: 0, + responseType: "binary-response", + }; + + global.fetch = vi.fn().mockResolvedValue( + new Response( + stream.Readable.toWeb(fs.createReadStream(join(__dirname, "test-file.txt"))) as ReadableStream, + { + status: 200, + statusText: "OK", + }, + ), + ); + + const result = await fetcherImpl(mockArgs); + + expect(global.fetch).toHaveBeenCalledWith( + url, + expect.objectContaining({ + method: "GET", + headers: expect.toContainHeaders({ "X-Test": "x-test-header" }), + }), + ); + expect(result.ok).toBe(true); + if (result.ok) { + const body = result.body as BinaryResponse; + expect(body).toBeDefined(); + expect(body.bodyUsed).toBe(false); + expect(typeof body.bytes).toBe("function"); + if (!body.bytes) { + return; + } + const bytes = await body.bytes(); + expect(bytes).toBeInstanceOf(Uint8Array); + const decoder = new TextDecoder(); + const streamContent = decoder.decode(bytes); + expect(streamContent).toBe("This is a test file!\n"); + expect(body.bodyUsed).toBe(true); + } + }); +}); diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/tests/unit/fetcher/HttpResponsePromise.test.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/tests/unit/fetcher/HttpResponsePromise.test.ts new file mode 100644 index 000000000000..2ec008e581d8 --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/tests/unit/fetcher/HttpResponsePromise.test.ts @@ -0,0 +1,143 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { HttpResponsePromise } from "../../../src/core/fetcher/HttpResponsePromise"; +import type { RawResponse, WithRawResponse } from "../../../src/core/fetcher/RawResponse"; + +describe("HttpResponsePromise", () => { + const mockRawResponse: RawResponse = { + headers: new Headers(), + redirected: false, + status: 200, + statusText: "OK", + type: "basic" as ResponseType, + url: "https://example.com", + }; + const mockData = { id: "123", name: "test" }; + const mockWithRawResponse: WithRawResponse = { + data: mockData, + rawResponse: mockRawResponse, + }; + + describe("fromFunction", () => { + it("should create an HttpResponsePromise from a function", async () => { + const mockFn = vi + .fn<(arg1: string, arg2: string) => Promise>>() + .mockResolvedValue(mockWithRawResponse); + + const responsePromise = HttpResponsePromise.fromFunction(mockFn, "arg1", "arg2"); + + const result = await responsePromise; + expect(result).toEqual(mockData); + expect(mockFn).toHaveBeenCalledWith("arg1", "arg2"); + + const resultWithRawResponse = await responsePromise.withRawResponse(); + expect(resultWithRawResponse).toEqual({ + data: mockData, + rawResponse: mockRawResponse, + }); + }); + }); + + describe("fromPromise", () => { + it("should create an HttpResponsePromise from a promise", async () => { + const promise = Promise.resolve(mockWithRawResponse); + + const responsePromise = HttpResponsePromise.fromPromise(promise); + + const result = await responsePromise; + expect(result).toEqual(mockData); + + const resultWithRawResponse = await responsePromise.withRawResponse(); + expect(resultWithRawResponse).toEqual({ + data: mockData, + rawResponse: mockRawResponse, + }); + }); + }); + + describe("fromExecutor", () => { + it("should create an HttpResponsePromise from an executor function", async () => { + const responsePromise = HttpResponsePromise.fromExecutor((resolve) => { + resolve(mockWithRawResponse); + }); + + const result = await responsePromise; + expect(result).toEqual(mockData); + + const resultWithRawResponse = await responsePromise.withRawResponse(); + expect(resultWithRawResponse).toEqual({ + data: mockData, + rawResponse: mockRawResponse, + }); + }); + }); + + describe("fromResult", () => { + it("should create an HttpResponsePromise from a result", async () => { + const responsePromise = HttpResponsePromise.fromResult(mockWithRawResponse); + + const result = await responsePromise; + expect(result).toEqual(mockData); + + const resultWithRawResponse = await responsePromise.withRawResponse(); + expect(resultWithRawResponse).toEqual({ + data: mockData, + rawResponse: mockRawResponse, + }); + }); + }); + + describe("Promise methods", () => { + let responsePromise: HttpResponsePromise; + + beforeEach(() => { + responsePromise = HttpResponsePromise.fromResult(mockWithRawResponse); + }); + + it("should support then() method", async () => { + const result = await responsePromise.then((data) => ({ + ...data, + modified: true, + })); + + expect(result).toEqual({ + ...mockData, + modified: true, + }); + }); + + it("should support catch() method", async () => { + const errorResponsePromise = HttpResponsePromise.fromExecutor((_, reject) => { + reject(new Error("Test error")); + }); + + const catchSpy = vi.fn(); + await errorResponsePromise.catch(catchSpy); + + expect(catchSpy).toHaveBeenCalled(); + const error = catchSpy.mock.calls[0]?.[0]; + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe("Test error"); + }); + + it("should support finally() method", async () => { + const finallySpy = vi.fn(); + await responsePromise.finally(finallySpy); + + expect(finallySpy).toHaveBeenCalled(); + }); + }); + + describe("withRawResponse", () => { + it("should return both data and raw response", async () => { + const responsePromise = HttpResponsePromise.fromResult(mockWithRawResponse); + + const result = await responsePromise.withRawResponse(); + + expect(result).toEqual({ + data: mockData, + rawResponse: mockRawResponse, + }); + }); + }); +}); diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/tests/unit/fetcher/RawResponse.test.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/tests/unit/fetcher/RawResponse.test.ts new file mode 100644 index 000000000000..375ee3f38064 --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/tests/unit/fetcher/RawResponse.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; + +import { toRawResponse } from "../../../src/core/fetcher/RawResponse"; + +describe("RawResponse", () => { + describe("toRawResponse", () => { + it("should convert Response to RawResponse by removing body, bodyUsed, and ok properties", () => { + const mockHeaders = new Headers({ "content-type": "application/json" }); + const mockResponse = { + body: "test body", + bodyUsed: false, + ok: true, + headers: mockHeaders, + redirected: false, + status: 200, + statusText: "OK", + type: "basic" as ResponseType, + url: "https://example.com", + }; + + const result = toRawResponse(mockResponse as unknown as Response); + + expect("body" in result).toBe(false); + expect("bodyUsed" in result).toBe(false); + expect("ok" in result).toBe(false); + expect(result.headers).toBe(mockHeaders); + expect(result.redirected).toBe(false); + expect(result.status).toBe(200); + expect(result.statusText).toBe("OK"); + expect(result.type).toBe("basic"); + expect(result.url).toBe("https://example.com"); + }); + }); +}); diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/tests/unit/fetcher/createRequestUrl.test.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/tests/unit/fetcher/createRequestUrl.test.ts new file mode 100644 index 000000000000..a92f1b5e81d1 --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/tests/unit/fetcher/createRequestUrl.test.ts @@ -0,0 +1,163 @@ +import { createRequestUrl } from "../../../src/core/fetcher/createRequestUrl"; + +describe("Test createRequestUrl", () => { + const BASE_URL = "https://api.example.com"; + + interface TestCase { + description: string; + baseUrl: string; + queryParams?: Record; + expected: string; + } + + const testCases: TestCase[] = [ + { + description: "should return the base URL when no query parameters are provided", + baseUrl: BASE_URL, + expected: BASE_URL, + }, + { + description: "should append simple query parameters", + baseUrl: BASE_URL, + queryParams: { key: "value", another: "param" }, + expected: "https://api.example.com?key=value&another=param", + }, + { + description: "should handle array query parameters", + baseUrl: BASE_URL, + queryParams: { items: ["a", "b", "c"] }, + expected: "https://api.example.com?items=a&items=b&items=c", + }, + { + description: "should handle object query parameters", + baseUrl: BASE_URL, + queryParams: { filter: { name: "John", age: 30 } }, + expected: "https://api.example.com?filter%5Bname%5D=John&filter%5Bage%5D=30", + }, + { + description: "should handle mixed types of query parameters", + baseUrl: BASE_URL, + queryParams: { + simple: "value", + array: ["x", "y"], + object: { key: "value" }, + }, + expected: "https://api.example.com?simple=value&array=x&array=y&object%5Bkey%5D=value", + }, + { + description: "should handle empty query parameters object", + baseUrl: BASE_URL, + queryParams: {}, + expected: BASE_URL, + }, + { + description: "should encode special characters in query parameters", + baseUrl: BASE_URL, + queryParams: { special: "a&b=c d" }, + expected: "https://api.example.com?special=a%26b%3Dc%20d", + }, + { + description: "should handle numeric values", + baseUrl: BASE_URL, + queryParams: { count: 42, price: 19.99, active: 1, inactive: 0 }, + expected: "https://api.example.com?count=42&price=19.99&active=1&inactive=0", + }, + { + description: "should handle boolean values", + baseUrl: BASE_URL, + queryParams: { enabled: true, disabled: false }, + expected: "https://api.example.com?enabled=true&disabled=false", + }, + { + description: "should handle null and undefined values", + baseUrl: BASE_URL, + queryParams: { + valid: "value", + nullValue: null, + undefinedValue: undefined, + emptyString: "", + }, + expected: "https://api.example.com?valid=value&nullValue=&emptyString=", + }, + { + description: "should handle deeply nested objects", + baseUrl: BASE_URL, + queryParams: { + user: { + profile: { + name: "John", + settings: { theme: "dark" }, + }, + }, + }, + expected: + "https://api.example.com?user%5Bprofile%5D%5Bname%5D=John&user%5Bprofile%5D%5Bsettings%5D%5Btheme%5D=dark", + }, + { + description: "should handle arrays of objects", + baseUrl: BASE_URL, + queryParams: { + users: [ + { name: "John", age: 30 }, + { name: "Jane", age: 25 }, + ], + }, + expected: + "https://api.example.com?users%5Bname%5D=John&users%5Bage%5D=30&users%5Bname%5D=Jane&users%5Bage%5D=25", + }, + { + description: "should handle mixed arrays", + baseUrl: BASE_URL, + queryParams: { + mixed: ["string", 42, true, { key: "value" }], + }, + expected: "https://api.example.com?mixed=string&mixed=42&mixed=true&mixed%5Bkey%5D=value", + }, + { + description: "should handle empty arrays", + baseUrl: BASE_URL, + queryParams: { emptyArray: [] }, + expected: BASE_URL, + }, + { + description: "should handle empty objects", + baseUrl: BASE_URL, + queryParams: { emptyObject: {} }, + expected: BASE_URL, + }, + { + description: "should handle special characters in keys", + baseUrl: BASE_URL, + queryParams: { "key with spaces": "value", "key[with]brackets": "value" }, + expected: "https://api.example.com?key%20with%20spaces=value&key%5Bwith%5Dbrackets=value", + }, + { + description: "should handle URL with existing query parameters", + baseUrl: "https://api.example.com?existing=param", + queryParams: { new: "value" }, + expected: "https://api.example.com?existing=param?new=value", + }, + { + description: "should handle complex nested structures", + baseUrl: BASE_URL, + queryParams: { + filters: { + status: ["active", "pending"], + category: { + type: "electronics", + subcategories: ["phones", "laptops"], + }, + }, + sort: { field: "name", direction: "asc" }, + }, + expected: + "https://api.example.com?filters%5Bstatus%5D=active&filters%5Bstatus%5D=pending&filters%5Bcategory%5D%5Btype%5D=electronics&filters%5Bcategory%5D%5Bsubcategories%5D=phones&filters%5Bcategory%5D%5Bsubcategories%5D=laptops&sort%5Bfield%5D=name&sort%5Bdirection%5D=asc", + }, + ]; + + testCases.forEach(({ description, baseUrl, queryParams, expected }) => { + it(description, () => { + expect(createRequestUrl(baseUrl, queryParams)).toBe(expected); + }); + }); +}); diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/tests/unit/fetcher/getRequestBody.test.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/tests/unit/fetcher/getRequestBody.test.ts new file mode 100644 index 000000000000..8a6c3a57e211 --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/tests/unit/fetcher/getRequestBody.test.ts @@ -0,0 +1,129 @@ +import { getRequestBody } from "../../../src/core/fetcher/getRequestBody"; +import { RUNTIME } from "../../../src/core/runtime"; + +describe("Test getRequestBody", () => { + interface TestCase { + description: string; + input: any; + type: "json" | "form" | "file" | "bytes" | "other"; + expected: any; + skipCondition?: () => boolean; + } + + const testCases: TestCase[] = [ + { + description: "should stringify body if not FormData in Node environment", + input: { key: "value" }, + type: "json", + expected: '{"key":"value"}', + skipCondition: () => RUNTIME.type !== "node", + }, + { + description: "should stringify body if not FormData in browser environment", + input: { key: "value" }, + type: "json", + expected: '{"key":"value"}', + skipCondition: () => RUNTIME.type !== "browser", + }, + { + description: "should return the Uint8Array", + input: new Uint8Array([1, 2, 3]), + type: "bytes", + expected: new Uint8Array([1, 2, 3]), + }, + { + description: "should serialize objects for form-urlencoded content type", + input: { username: "johndoe", email: "john@example.com" }, + type: "form", + expected: "username=johndoe&email=john%40example.com", + }, + { + description: "should serialize complex nested objects and arrays for form-urlencoded content type", + input: { + user: { + profile: { + name: "John Doe", + settings: { + theme: "dark", + notifications: true, + }, + }, + tags: ["admin", "user"], + contacts: [ + { type: "email", value: "john@example.com" }, + { type: "phone", value: "+1234567890" }, + ], + }, + filters: { + status: ["active", "pending"], + metadata: { + created: "2024-01-01", + categories: ["electronics", "books"], + }, + }, + preferences: ["notifications", "updates"], + }, + type: "form", + expected: + "user%5Bprofile%5D%5Bname%5D=John%20Doe&" + + "user%5Bprofile%5D%5Bsettings%5D%5Btheme%5D=dark&" + + "user%5Bprofile%5D%5Bsettings%5D%5Bnotifications%5D=true&" + + "user%5Btags%5D=admin&" + + "user%5Btags%5D=user&" + + "user%5Bcontacts%5D%5Btype%5D=email&" + + "user%5Bcontacts%5D%5Bvalue%5D=john%40example.com&" + + "user%5Bcontacts%5D%5Btype%5D=phone&" + + "user%5Bcontacts%5D%5Bvalue%5D=%2B1234567890&" + + "filters%5Bstatus%5D=active&" + + "filters%5Bstatus%5D=pending&" + + "filters%5Bmetadata%5D%5Bcreated%5D=2024-01-01&" + + "filters%5Bmetadata%5D%5Bcategories%5D=electronics&" + + "filters%5Bmetadata%5D%5Bcategories%5D=books&" + + "preferences=notifications&" + + "preferences=updates", + }, + { + description: "should return the input for pre-serialized form-urlencoded strings", + input: "key=value&another=param", + type: "other", + expected: "key=value&another=param", + }, + { + description: "should JSON stringify objects", + input: { key: "value" }, + type: "json", + expected: '{"key":"value"}', + }, + ]; + + testCases.forEach(({ description, input, type, expected, skipCondition }) => { + it(description, async () => { + if (skipCondition?.()) { + return; + } + + const result = await getRequestBody({ + body: input, + type, + }); + + if (input instanceof Uint8Array) { + expect(result).toBe(input); + } else { + expect(result).toBe(expected); + } + }); + }); + + it("should return FormData in browser environment", async () => { + if (RUNTIME.type === "browser") { + const formData = new FormData(); + formData.append("key", "value"); + const result = await getRequestBody({ + body: formData, + type: "file", + }); + expect(result).toBe(formData); + } + }); +}); diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/tests/unit/fetcher/getResponseBody.test.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/tests/unit/fetcher/getResponseBody.test.ts new file mode 100644 index 000000000000..ad6be7fc2c9b --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/tests/unit/fetcher/getResponseBody.test.ts @@ -0,0 +1,97 @@ +import { getResponseBody } from "../../../src/core/fetcher/getResponseBody"; + +import { RUNTIME } from "../../../src/core/runtime"; + +describe("Test getResponseBody", () => { + interface SimpleTestCase { + description: string; + responseData: string | Record; + responseType?: "blob" | "sse" | "streaming" | "text"; + expected: any; + skipCondition?: () => boolean; + } + + const simpleTestCases: SimpleTestCase[] = [ + { + description: "should handle text response type", + responseData: "test text", + responseType: "text", + expected: "test text", + }, + { + description: "should handle JSON response", + responseData: { key: "value" }, + expected: { key: "value" }, + }, + { + description: "should handle empty response", + responseData: "", + expected: undefined, + }, + { + description: "should handle non-JSON response", + responseData: "invalid json", + expected: { + ok: false, + error: { + reason: "non-json", + statusCode: 200, + rawBody: "invalid json", + }, + }, + }, + ]; + + simpleTestCases.forEach(({ description, responseData, responseType, expected, skipCondition }) => { + it(description, async () => { + if (skipCondition?.()) { + return; + } + + const mockResponse = new Response( + typeof responseData === "string" ? responseData : JSON.stringify(responseData), + ); + const result = await getResponseBody(mockResponse, responseType); + expect(result).toEqual(expected); + }); + }); + + it("should handle blob response type", async () => { + const mockBlob = new Blob(["test"], { type: "text/plain" }); + const mockResponse = new Response(mockBlob); + const result = await getResponseBody(mockResponse, "blob"); + // @ts-expect-error + expect(result.constructor.name).toBe("Blob"); + }); + + it("should handle sse response type", async () => { + if (RUNTIME.type === "node") { + const mockStream = new ReadableStream(); + const mockResponse = new Response(mockStream); + const result = await getResponseBody(mockResponse, "sse"); + expect(result).toBe(mockStream); + } + }); + + it("should handle streaming response type", async () => { + const encoder = new TextEncoder(); + const testData = "test stream data"; + const mockStream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(testData)); + controller.close(); + }, + }); + + const mockResponse = new Response(mockStream); + const result = (await getResponseBody(mockResponse, "streaming")) as ReadableStream; + + expect(result).toBeInstanceOf(ReadableStream); + + const reader = result.getReader(); + const decoder = new TextDecoder(); + const { value } = await reader.read(); + const streamContent = decoder.decode(value); + expect(streamContent).toBe(testData); + }); +}); diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/tests/unit/fetcher/logging.test.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/tests/unit/fetcher/logging.test.ts new file mode 100644 index 000000000000..366c9b6ced61 --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/tests/unit/fetcher/logging.test.ts @@ -0,0 +1,517 @@ +import { fetcherImpl } from "../../../src/core/fetcher/Fetcher"; + +function createMockLogger() { + return { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; +} + +function mockSuccessResponse(data: unknown = { data: "test" }, status = 200, statusText = "OK") { + global.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify(data), { + status, + statusText, + }), + ); +} + +function mockErrorResponse(data: unknown = { error: "Error" }, status = 404, statusText = "Not Found") { + global.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify(data), { + status, + statusText, + }), + ); +} + +describe("Fetcher Logging Integration", () => { + describe("Request Logging", () => { + it("should log successful request at debug level", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://example.com/api", + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { test: "data" }, + contentType: "application/json", + requestType: "json", + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + method: "POST", + url: "https://example.com/api", + headers: expect.toContainHeaders({ + "Content-Type": "application/json", + }), + hasBody: true, + }), + ); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "HTTP request succeeded", + expect.objectContaining({ + method: "POST", + url: "https://example.com/api", + statusCode: 200, + }), + ); + }); + + it("should not log debug messages at info level for successful requests", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://example.com/api", + method: "GET", + responseType: "json", + maxRetries: 0, + logging: { + level: "info", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).not.toHaveBeenCalled(); + expect(mockLogger.info).not.toHaveBeenCalled(); + }); + + it("should log request with body flag", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://example.com/api", + method: "POST", + body: { data: "test" }, + contentType: "application/json", + requestType: "json", + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + hasBody: true, + }), + ); + }); + + it("should log request without body flag", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://example.com/api", + method: "GET", + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + hasBody: false, + }), + ); + }); + + it("should not log when silent mode is enabled", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://example.com/api", + method: "GET", + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: true, + }, + }); + + expect(mockLogger.debug).not.toHaveBeenCalled(); + expect(mockLogger.info).not.toHaveBeenCalled(); + expect(mockLogger.warn).not.toHaveBeenCalled(); + expect(mockLogger.error).not.toHaveBeenCalled(); + }); + + it("should not log when no logging config is provided", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://example.com/api", + method: "GET", + responseType: "json", + maxRetries: 0, + }); + + expect(mockLogger.debug).not.toHaveBeenCalled(); + }); + }); + + describe("Error Logging", () => { + it("should log 4xx errors at error level", async () => { + const mockLogger = createMockLogger(); + mockErrorResponse({ error: "Not found" }, 404, "Not Found"); + + const result = await fetcherImpl({ + url: "https://example.com/api", + method: "GET", + responseType: "json", + maxRetries: 0, + logging: { + level: "error", + logger: mockLogger, + silent: false, + }, + }); + + expect(result.ok).toBe(false); + expect(mockLogger.error).toHaveBeenCalledWith( + "HTTP request failed with error status", + expect.objectContaining({ + method: "GET", + url: "https://example.com/api", + statusCode: 404, + }), + ); + }); + + it("should log 5xx errors at error level", async () => { + const mockLogger = createMockLogger(); + mockErrorResponse({ error: "Internal error" }, 500, "Internal Server Error"); + + const result = await fetcherImpl({ + url: "https://example.com/api", + method: "GET", + responseType: "json", + maxRetries: 0, + logging: { + level: "error", + logger: mockLogger, + silent: false, + }, + }); + + expect(result.ok).toBe(false); + expect(mockLogger.error).toHaveBeenCalledWith( + "HTTP request failed with error status", + expect.objectContaining({ + method: "GET", + url: "https://example.com/api", + statusCode: 500, + }), + ); + }); + + it("should log aborted request errors", async () => { + const mockLogger = createMockLogger(); + + const abortController = new AbortController(); + abortController.abort(); + + global.fetch = vi.fn().mockRejectedValue(new Error("Aborted")); + + const result = await fetcherImpl({ + url: "https://example.com/api", + method: "GET", + responseType: "json", + abortSignal: abortController.signal, + maxRetries: 0, + logging: { + level: "error", + logger: mockLogger, + silent: false, + }, + }); + + expect(result.ok).toBe(false); + expect(mockLogger.error).toHaveBeenCalledWith( + "HTTP request was aborted", + expect.objectContaining({ + method: "GET", + url: "https://example.com/api", + }), + ); + }); + + it("should log timeout errors", async () => { + const mockLogger = createMockLogger(); + + const timeoutError = new Error("Request timeout"); + timeoutError.name = "AbortError"; + + global.fetch = vi.fn().mockRejectedValue(timeoutError); + + const result = await fetcherImpl({ + url: "https://example.com/api", + method: "GET", + responseType: "json", + maxRetries: 0, + logging: { + level: "error", + logger: mockLogger, + silent: false, + }, + }); + + expect(result.ok).toBe(false); + expect(mockLogger.error).toHaveBeenCalledWith( + "HTTP request timed out", + expect.objectContaining({ + method: "GET", + url: "https://example.com/api", + timeoutMs: undefined, + }), + ); + }); + + it("should log unknown errors", async () => { + const mockLogger = createMockLogger(); + + const unknownError = new Error("Unknown error"); + + global.fetch = vi.fn().mockRejectedValue(unknownError); + + const result = await fetcherImpl({ + url: "https://example.com/api", + method: "GET", + responseType: "json", + maxRetries: 0, + logging: { + level: "error", + logger: mockLogger, + silent: false, + }, + }); + + expect(result.ok).toBe(false); + expect(mockLogger.error).toHaveBeenCalledWith( + "HTTP request failed with error", + expect.objectContaining({ + method: "GET", + url: "https://example.com/api", + errorMessage: "Unknown error", + }), + ); + }); + }); + + describe("Logging with Redaction", () => { + it("should redact sensitive data in error logs", async () => { + const mockLogger = createMockLogger(); + mockErrorResponse({ error: "Unauthorized" }, 401, "Unauthorized"); + + await fetcherImpl({ + url: "https://example.com/api?api_key=secret", + method: "GET", + responseType: "json", + maxRetries: 0, + logging: { + level: "error", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.error).toHaveBeenCalledWith( + "HTTP request failed with error status", + expect.objectContaining({ + url: "https://example.com/api?api_key=[REDACTED]", + }), + ); + }); + }); + + describe("Different HTTP Methods", () => { + it("should log GET requests", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://example.com/api", + method: "GET", + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + method: "GET", + }), + ); + }); + + it("should log POST requests", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse({ data: "test" }, 201, "Created"); + + await fetcherImpl({ + url: "https://example.com/api", + method: "POST", + body: { data: "test" }, + contentType: "application/json", + requestType: "json", + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + method: "POST", + }), + ); + }); + + it("should log PUT requests", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://example.com/api", + method: "PUT", + body: { data: "test" }, + contentType: "application/json", + requestType: "json", + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + method: "PUT", + }), + ); + }); + + it("should log DELETE requests", async () => { + const mockLogger = createMockLogger(); + global.fetch = vi.fn().mockResolvedValue( + new Response(null, { + status: 200, + statusText: "OK", + }), + ); + + await fetcherImpl({ + url: "https://example.com/api", + method: "DELETE", + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + method: "DELETE", + }), + ); + }); + }); + + describe("Status Code Logging", () => { + it("should log 2xx success status codes", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse({ data: "test" }, 201, "Created"); + + await fetcherImpl({ + url: "https://example.com/api", + method: "POST", + body: { data: "test" }, + contentType: "application/json", + requestType: "json", + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "HTTP request succeeded", + expect.objectContaining({ + statusCode: 201, + }), + ); + }); + + it("should log 3xx redirect status codes as success", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse({ data: "test" }, 301, "Moved Permanently"); + + await fetcherImpl({ + url: "https://example.com/api", + method: "GET", + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "HTTP request succeeded", + expect.objectContaining({ + statusCode: 301, + }), + ); + }); + }); +}); diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/tests/unit/fetcher/makeRequest.test.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/tests/unit/fetcher/makeRequest.test.ts new file mode 100644 index 000000000000..ea49466a55fc --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/tests/unit/fetcher/makeRequest.test.ts @@ -0,0 +1,54 @@ +import type { Mock } from "vitest"; +import { makeRequest } from "../../../src/core/fetcher/makeRequest"; + +describe("Test makeRequest", () => { + const mockPostUrl = "https://httpbin.org/post"; + const mockGetUrl = "https://httpbin.org/get"; + const mockHeaders = { "Content-Type": "application/json" }; + const mockBody = JSON.stringify({ key: "value" }); + + let mockFetch: Mock; + + beforeEach(() => { + mockFetch = vi.fn(); + mockFetch.mockResolvedValue(new Response(JSON.stringify({ test: "successful" }), { status: 200 })); + }); + + it("should handle POST request correctly", async () => { + const response = await makeRequest(mockFetch, mockPostUrl, "POST", mockHeaders, mockBody); + const responseBody = await response.json(); + expect(responseBody).toEqual({ test: "successful" }); + expect(mockFetch).toHaveBeenCalledTimes(1); + const [calledUrl, calledOptions] = mockFetch.mock.calls[0]; + expect(calledUrl).toBe(mockPostUrl); + expect(calledOptions).toEqual( + expect.objectContaining({ + method: "POST", + headers: mockHeaders, + body: mockBody, + credentials: undefined, + }), + ); + expect(calledOptions.signal).toBeDefined(); + expect(calledOptions.signal).toBeInstanceOf(AbortSignal); + }); + + it("should handle GET request correctly", async () => { + const response = await makeRequest(mockFetch, mockGetUrl, "GET", mockHeaders, undefined); + const responseBody = await response.json(); + expect(responseBody).toEqual({ test: "successful" }); + expect(mockFetch).toHaveBeenCalledTimes(1); + const [calledUrl, calledOptions] = mockFetch.mock.calls[0]; + expect(calledUrl).toBe(mockGetUrl); + expect(calledOptions).toEqual( + expect.objectContaining({ + method: "GET", + headers: mockHeaders, + body: undefined, + credentials: undefined, + }), + ); + expect(calledOptions.signal).toBeDefined(); + expect(calledOptions.signal).toBeInstanceOf(AbortSignal); + }); +}); diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/tests/unit/fetcher/redacting.test.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/tests/unit/fetcher/redacting.test.ts new file mode 100644 index 000000000000..d599376b9bcf --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/tests/unit/fetcher/redacting.test.ts @@ -0,0 +1,1115 @@ +import { fetcherImpl } from "../../../src/core/fetcher/Fetcher"; + +function createMockLogger() { + return { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; +} + +function mockSuccessResponse(data: unknown = { data: "test" }, status = 200, statusText = "OK") { + global.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify(data), { + status, + statusText, + }), + ); +} + +describe("Redacting Logic", () => { + describe("Header Redaction", () => { + it("should redact authorization header", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://example.com/api", + method: "GET", + headers: { Authorization: "Bearer secret-token-12345" }, + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + headers: expect.toContainHeaders({ + Authorization: "[REDACTED]", + }), + }), + ); + }); + + it("should redact api-key header (case-insensitive)", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://example.com/api", + method: "GET", + headers: { "X-API-KEY": "secret-api-key" }, + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + headers: expect.toContainHeaders({ + "X-API-KEY": "[REDACTED]", + }), + }), + ); + }); + + it("should redact cookie header", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://example.com/api", + method: "GET", + headers: { Cookie: "session=abc123; token=xyz789" }, + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + headers: expect.toContainHeaders({ + Cookie: "[REDACTED]", + }), + }), + ); + }); + + it("should redact x-auth-token header", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://example.com/api", + method: "GET", + headers: { "x-auth-token": "auth-token-12345" }, + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + headers: expect.toContainHeaders({ + "x-auth-token": "[REDACTED]", + }), + }), + ); + }); + + it("should redact proxy-authorization header", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://example.com/api", + method: "GET", + headers: { "Proxy-Authorization": "Basic credentials" }, + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + headers: expect.toContainHeaders({ + "Proxy-Authorization": "[REDACTED]", + }), + }), + ); + }); + + it("should redact x-csrf-token header", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://example.com/api", + method: "GET", + headers: { "X-CSRF-Token": "csrf-token-abc" }, + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + headers: expect.toContainHeaders({ + "X-CSRF-Token": "[REDACTED]", + }), + }), + ); + }); + + it("should redact www-authenticate header", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://example.com/api", + method: "GET", + headers: { "WWW-Authenticate": "Bearer realm=example" }, + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + headers: expect.toContainHeaders({ + "WWW-Authenticate": "[REDACTED]", + }), + }), + ); + }); + + it("should redact x-session-token header", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://example.com/api", + method: "GET", + headers: { "X-Session-Token": "session-token-xyz" }, + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + headers: expect.toContainHeaders({ + "X-Session-Token": "[REDACTED]", + }), + }), + ); + }); + + it("should not redact non-sensitive headers", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://example.com/api", + method: "GET", + headers: { + "Content-Type": "application/json", + "User-Agent": "Test/1.0", + Accept: "application/json", + }, + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + headers: expect.toContainHeaders({ + "Content-Type": "application/json", + "User-Agent": "Test/1.0", + Accept: "application/json", + }), + }), + ); + }); + + it("should redact multiple sensitive headers at once", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://example.com/api", + method: "GET", + headers: { + Authorization: "Bearer token", + "X-API-Key": "api-key", + Cookie: "session=123", + "Content-Type": "application/json", + }, + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + headers: expect.toContainHeaders({ + Authorization: "[REDACTED]", + "X-API-Key": "[REDACTED]", + Cookie: "[REDACTED]", + "Content-Type": "application/json", + }), + }), + ); + }); + }); + + describe("Response Header Redaction", () => { + it("should redact Set-Cookie in response headers", async () => { + const mockLogger = createMockLogger(); + + const mockHeaders = new Headers(); + mockHeaders.set("Set-Cookie", "session=abc123; HttpOnly; Secure"); + mockHeaders.set("Content-Type", "application/json"); + + global.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ data: "test" }), { + status: 200, + statusText: "OK", + headers: mockHeaders, + }), + ); + + await fetcherImpl({ + url: "https://example.com/api", + method: "GET", + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "HTTP request succeeded", + expect.objectContaining({ + responseHeaders: expect.toContainHeaders({ + "set-cookie": "[REDACTED]", + "content-type": "application/json", + }), + }), + ); + }); + + it("should redact authorization in response headers", async () => { + const mockLogger = createMockLogger(); + + const mockHeaders = new Headers(); + mockHeaders.set("Authorization", "Bearer token-123"); + mockHeaders.set("Content-Type", "application/json"); + + global.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ data: "test" }), { + status: 200, + statusText: "OK", + headers: mockHeaders, + }), + ); + + await fetcherImpl({ + url: "https://example.com/api", + method: "GET", + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "HTTP request succeeded", + expect.objectContaining({ + responseHeaders: expect.toContainHeaders({ + authorization: "[REDACTED]", + "content-type": "application/json", + }), + }), + ); + }); + + it("should redact response headers in error responses", async () => { + const mockLogger = createMockLogger(); + + const mockHeaders = new Headers(); + mockHeaders.set("WWW-Authenticate", "Bearer realm=example"); + mockHeaders.set("Content-Type", "application/json"); + + global.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + statusText: "Unauthorized", + headers: mockHeaders, + }), + ); + + await fetcherImpl({ + url: "https://example.com/api", + method: "GET", + responseType: "json", + maxRetries: 0, + logging: { + level: "error", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.error).toHaveBeenCalledWith( + "HTTP request failed with error status", + expect.objectContaining({ + responseHeaders: expect.toContainHeaders({ + "www-authenticate": "[REDACTED]", + "content-type": "application/json", + }), + }), + ); + }); + }); + + describe("Query Parameter Redaction", () => { + it("should redact api_key query parameter", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://example.com/api", + method: "GET", + queryParameters: { api_key: "secret-key" }, + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + queryParameters: expect.objectContaining({ + api_key: "[REDACTED]", + }), + }), + ); + }); + + it("should redact token query parameter", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://example.com/api", + method: "GET", + queryParameters: { token: "secret-token" }, + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + queryParameters: expect.objectContaining({ + token: "[REDACTED]", + }), + }), + ); + }); + + it("should redact access_token query parameter", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://example.com/api", + method: "GET", + queryParameters: { access_token: "secret-access-token" }, + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + queryParameters: expect.objectContaining({ + access_token: "[REDACTED]", + }), + }), + ); + }); + + it("should redact password query parameter", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://example.com/api", + method: "GET", + queryParameters: { password: "secret-password" }, + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + queryParameters: expect.objectContaining({ + password: "[REDACTED]", + }), + }), + ); + }); + + it("should redact secret query parameter", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://example.com/api", + method: "GET", + queryParameters: { secret: "secret-value" }, + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + queryParameters: expect.objectContaining({ + secret: "[REDACTED]", + }), + }), + ); + }); + + it("should redact session_id query parameter", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://example.com/api", + method: "GET", + queryParameters: { session_id: "session-123" }, + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + queryParameters: expect.objectContaining({ + session_id: "[REDACTED]", + }), + }), + ); + }); + + it("should not redact non-sensitive query parameters", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://example.com/api", + method: "GET", + queryParameters: { + page: "1", + limit: "10", + sort: "name", + }, + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + queryParameters: expect.objectContaining({ + page: "1", + limit: "10", + sort: "name", + }), + }), + ); + }); + + it("should not redact parameters containing 'auth' substring like 'author'", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://example.com/api", + method: "GET", + queryParameters: { + author: "john", + authenticate: "false", + authorization_level: "user", + }, + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + queryParameters: expect.objectContaining({ + author: "john", + authenticate: "false", + authorization_level: "user", + }), + }), + ); + }); + + it("should handle undefined query parameters", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://example.com/api", + method: "GET", + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + queryParameters: undefined, + }), + ); + }); + + it("should redact case-insensitive query parameters", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://example.com/api", + method: "GET", + queryParameters: { API_KEY: "secret-key", Token: "secret-token" }, + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + queryParameters: expect.objectContaining({ + API_KEY: "[REDACTED]", + Token: "[REDACTED]", + }), + }), + ); + }); + }); + + describe("URL Redaction", () => { + it("should redact credentials in URL", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://user:password@example.com/api", + method: "GET", + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + url: "https://[REDACTED]@example.com/api", + }), + ); + }); + + it("should redact api_key in query string", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://example.com/api?api_key=secret-key&page=1", + method: "GET", + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + url: "https://example.com/api?api_key=[REDACTED]&page=1", + }), + ); + }); + + it("should redact token in query string", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://example.com/api?token=secret-token", + method: "GET", + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + url: "https://example.com/api?token=[REDACTED]", + }), + ); + }); + + it("should redact password in query string", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://example.com/api?username=user&password=secret", + method: "GET", + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + url: "https://example.com/api?username=user&password=[REDACTED]", + }), + ); + }); + + it("should not redact non-sensitive query strings", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://example.com/api?page=1&limit=10&sort=name", + method: "GET", + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + url: "https://example.com/api?page=1&limit=10&sort=name", + }), + ); + }); + + it("should not redact URL parameters containing 'auth' substring like 'author'", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://example.com/api?author=john&authenticate=false&page=1", + method: "GET", + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + url: "https://example.com/api?author=john&authenticate=false&page=1", + }), + ); + }); + + it("should handle URL with fragment", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://example.com/api?token=secret#section", + method: "GET", + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + url: "https://example.com/api?token=[REDACTED]#section", + }), + ); + }); + + it("should redact URL-encoded query parameters", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://example.com/api?api%5Fkey=secret", + method: "GET", + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + url: "https://example.com/api?api%5Fkey=[REDACTED]", + }), + ); + }); + + it("should handle URL without query string", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://example.com/api", + method: "GET", + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + url: "https://example.com/api", + }), + ); + }); + + it("should handle empty query string", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://example.com/api?", + method: "GET", + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + url: "https://example.com/api?", + }), + ); + }); + + it("should redact multiple sensitive parameters in URL", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://example.com/api?api_key=secret1&token=secret2&page=1", + method: "GET", + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + url: "https://example.com/api?api_key=[REDACTED]&token=[REDACTED]&page=1", + }), + ); + }); + + it("should redact both credentials and query parameters", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://user:pass@example.com/api?token=secret", + method: "GET", + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + url: "https://[REDACTED]@example.com/api?token=[REDACTED]", + }), + ); + }); + + it("should use fast path for URLs without sensitive keywords", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://example.com/api?page=1&limit=10&sort=name&filter=value", + method: "GET", + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + url: "https://example.com/api?page=1&limit=10&sort=name&filter=value", + }), + ); + }); + + it("should handle query parameter without value", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://example.com/api?flag&token=secret", + method: "GET", + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + url: "https://example.com/api?flag&token=[REDACTED]", + }), + ); + }); + + it("should handle URL with multiple @ symbols in credentials", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://user@example.com:pass@host.com/api", + method: "GET", + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + url: "https://[REDACTED]@host.com/api", + }), + ); + }); + + it("should handle URL with @ in query parameter but not in credentials", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://example.com/api?email=user@example.com", + method: "GET", + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + url: "https://example.com/api?email=user@example.com", + }), + ); + }); + + it("should handle URL with both credentials and @ in path", async () => { + const mockLogger = createMockLogger(); + mockSuccessResponse(); + + await fetcherImpl({ + url: "https://user:pass@example.com/users/@username", + method: "GET", + responseType: "json", + maxRetries: 0, + logging: { + level: "debug", + logger: mockLogger, + silent: false, + }, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + "Making HTTP request", + expect.objectContaining({ + url: "https://[REDACTED]@example.com/users/@username", + }), + ); + }); + }); +}); diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/tests/unit/fetcher/requestWithRetries.test.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/tests/unit/fetcher/requestWithRetries.test.ts new file mode 100644 index 000000000000..d22661367f4e --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/tests/unit/fetcher/requestWithRetries.test.ts @@ -0,0 +1,230 @@ +import type { Mock, MockInstance } from "vitest"; +import { requestWithRetries } from "../../../src/core/fetcher/requestWithRetries"; + +describe("requestWithRetries", () => { + let mockFetch: Mock; + let originalMathRandom: typeof Math.random; + let setTimeoutSpy: MockInstance; + + beforeEach(() => { + mockFetch = vi.fn(); + originalMathRandom = Math.random; + + Math.random = vi.fn(() => 0.5); + + vi.useFakeTimers({ + toFake: [ + "setTimeout", + "clearTimeout", + "setInterval", + "clearInterval", + "setImmediate", + "clearImmediate", + "Date", + "performance", + "requestAnimationFrame", + "cancelAnimationFrame", + "requestIdleCallback", + "cancelIdleCallback", + ], + }); + }); + + afterEach(() => { + Math.random = originalMathRandom; + vi.clearAllMocks(); + vi.clearAllTimers(); + }); + + it("should retry on retryable status codes", async () => { + setTimeoutSpy = vi.spyOn(global, "setTimeout").mockImplementation((callback: (args: void) => void) => { + process.nextTick(callback); + return null as any; + }); + + const retryableStatuses = [408, 429, 500, 502]; + let callCount = 0; + + mockFetch.mockImplementation(async () => { + if (callCount < retryableStatuses.length) { + return new Response("", { status: retryableStatuses[callCount++] }); + } + return new Response("", { status: 200 }); + }); + + const responsePromise = requestWithRetries(() => mockFetch(), retryableStatuses.length); + await vi.runAllTimersAsync(); + const response = await responsePromise; + + expect(mockFetch).toHaveBeenCalledTimes(retryableStatuses.length + 1); + expect(response.status).toBe(200); + }); + + it("should respect maxRetries limit", async () => { + setTimeoutSpy = vi.spyOn(global, "setTimeout").mockImplementation((callback: (args: void) => void) => { + process.nextTick(callback); + return null as any; + }); + + const maxRetries = 2; + mockFetch.mockResolvedValue(new Response("", { status: 500 })); + + const responsePromise = requestWithRetries(() => mockFetch(), maxRetries); + await vi.runAllTimersAsync(); + const response = await responsePromise; + + expect(mockFetch).toHaveBeenCalledTimes(maxRetries + 1); + expect(response.status).toBe(500); + }); + + it("should not retry on success status codes", async () => { + setTimeoutSpy = vi.spyOn(global, "setTimeout").mockImplementation((callback: (args: void) => void) => { + process.nextTick(callback); + return null as any; + }); + + const successStatuses = [200, 201, 202]; + + for (const status of successStatuses) { + mockFetch.mockReset(); + setTimeoutSpy.mockClear(); + mockFetch.mockResolvedValueOnce(new Response("", { status })); + + const responsePromise = requestWithRetries(() => mockFetch(), 3); + await vi.runAllTimersAsync(); + await responsePromise; + + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(setTimeoutSpy).not.toHaveBeenCalled(); + } + }); + + interface RetryHeaderTestCase { + description: string; + headerName: string; + headerValue: string | (() => string); + expectedDelayMin: number; + expectedDelayMax: number; + } + + const retryHeaderTests: RetryHeaderTestCase[] = [ + { + description: "should respect retry-after header with seconds value", + headerName: "retry-after", + headerValue: "5", + expectedDelayMin: 4000, + expectedDelayMax: 6000, + }, + { + description: "should respect retry-after header with HTTP date value", + headerName: "retry-after", + headerValue: () => new Date(Date.now() + 3000).toUTCString(), + expectedDelayMin: 2000, + expectedDelayMax: 4000, + }, + { + description: "should respect x-ratelimit-reset header", + headerName: "x-ratelimit-reset", + headerValue: () => Math.floor((Date.now() + 4000) / 1000).toString(), + expectedDelayMin: 3000, + expectedDelayMax: 6000, + }, + ]; + + retryHeaderTests.forEach(({ description, headerName, headerValue, expectedDelayMin, expectedDelayMax }) => { + it(description, async () => { + setTimeoutSpy = vi.spyOn(global, "setTimeout").mockImplementation((callback: (args: void) => void) => { + process.nextTick(callback); + return null as any; + }); + + const value = typeof headerValue === "function" ? headerValue() : headerValue; + mockFetch + .mockResolvedValueOnce( + new Response("", { + status: 429, + headers: new Headers({ [headerName]: value }), + }), + ) + .mockResolvedValueOnce(new Response("", { status: 200 })); + + const responsePromise = requestWithRetries(() => mockFetch(), 1); + await vi.runAllTimersAsync(); + const response = await responsePromise; + + expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), expect.any(Number)); + const actualDelay = setTimeoutSpy.mock.calls[0][1]; + expect(actualDelay).toBeGreaterThan(expectedDelayMin); + expect(actualDelay).toBeLessThan(expectedDelayMax); + expect(response.status).toBe(200); + }); + }); + + it("should apply correct exponential backoff with jitter", async () => { + setTimeoutSpy = vi.spyOn(global, "setTimeout").mockImplementation((callback: (args: void) => void) => { + process.nextTick(callback); + return null as any; + }); + + mockFetch.mockResolvedValue(new Response("", { status: 500 })); + const maxRetries = 3; + const expectedDelays = [1000, 2000, 4000]; + + const responsePromise = requestWithRetries(() => mockFetch(), maxRetries); + await vi.runAllTimersAsync(); + await responsePromise; + + expect(setTimeoutSpy).toHaveBeenCalledTimes(expectedDelays.length); + + expectedDelays.forEach((delay, index) => { + expect(setTimeoutSpy).toHaveBeenNthCalledWith(index + 1, expect.any(Function), delay); + }); + + expect(mockFetch).toHaveBeenCalledTimes(maxRetries + 1); + }); + + it("should handle concurrent retries independently", async () => { + setTimeoutSpy = vi.spyOn(global, "setTimeout").mockImplementation((callback: (args: void) => void) => { + process.nextTick(callback); + return null as any; + }); + + mockFetch + .mockResolvedValueOnce(new Response("", { status: 500 })) + .mockResolvedValueOnce(new Response("", { status: 500 })) + .mockResolvedValueOnce(new Response("", { status: 200 })) + .mockResolvedValueOnce(new Response("", { status: 200 })); + + const promise1 = requestWithRetries(() => mockFetch(), 1); + const promise2 = requestWithRetries(() => mockFetch(), 1); + + await vi.runAllTimersAsync(); + const [response1, response2] = await Promise.all([promise1, promise2]); + + expect(response1.status).toBe(200); + expect(response2.status).toBe(200); + }); + + it("should cap delay at MAX_RETRY_DELAY for large header values", async () => { + setTimeoutSpy = vi.spyOn(global, "setTimeout").mockImplementation((callback: (args: void) => void) => { + process.nextTick(callback); + return null as any; + }); + + mockFetch + .mockResolvedValueOnce( + new Response("", { + status: 429, + headers: new Headers({ "retry-after": "120" }), // 120 seconds = 120000ms > MAX_RETRY_DELAY (60000ms) + }), + ) + .mockResolvedValueOnce(new Response("", { status: 200 })); + + const responsePromise = requestWithRetries(() => mockFetch(), 1); + await vi.runAllTimersAsync(); + const response = await responsePromise; + + expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 60000); + expect(response.status).toBe(200); + }); +}); diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/tests/unit/fetcher/signals.test.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/tests/unit/fetcher/signals.test.ts new file mode 100644 index 000000000000..d7b6d1e63caa --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/tests/unit/fetcher/signals.test.ts @@ -0,0 +1,69 @@ +import { anySignal, getTimeoutSignal } from "../../../src/core/fetcher/signals"; + +describe("Test getTimeoutSignal", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("should return an object with signal and abortId", () => { + const { signal, abortId } = getTimeoutSignal(1000); + + expect(signal).toBeDefined(); + expect(abortId).toBeDefined(); + expect(signal).toBeInstanceOf(AbortSignal); + expect(signal.aborted).toBe(false); + }); + + it("should create a signal that aborts after the specified timeout", () => { + const timeoutMs = 5000; + const { signal } = getTimeoutSignal(timeoutMs); + + expect(signal.aborted).toBe(false); + + vi.advanceTimersByTime(timeoutMs - 1); + expect(signal.aborted).toBe(false); + + vi.advanceTimersByTime(1); + expect(signal.aborted).toBe(true); + }); +}); + +describe("Test anySignal", () => { + it("should return an AbortSignal", () => { + const signal = anySignal(new AbortController().signal); + expect(signal).toBeInstanceOf(AbortSignal); + }); + + it("should abort when any of the input signals is aborted", () => { + const controller1 = new AbortController(); + const controller2 = new AbortController(); + const signal = anySignal(controller1.signal, controller2.signal); + + expect(signal.aborted).toBe(false); + controller1.abort(); + expect(signal.aborted).toBe(true); + }); + + it("should handle an array of signals", () => { + const controller1 = new AbortController(); + const controller2 = new AbortController(); + const signal = anySignal([controller1.signal, controller2.signal]); + + expect(signal.aborted).toBe(false); + controller2.abort(); + expect(signal.aborted).toBe(true); + }); + + it("should abort immediately if one of the input signals is already aborted", () => { + const controller1 = new AbortController(); + const controller2 = new AbortController(); + controller1.abort(); + + const signal = anySignal(controller1.signal, controller2.signal); + expect(signal.aborted).toBe(true); + }); +}); diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/tests/unit/fetcher/test-file.txt b/seed/ts-sdk/any-auth/discriminated-union-auth/tests/unit/fetcher/test-file.txt new file mode 100644 index 000000000000..c66d471e359c --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/tests/unit/fetcher/test-file.txt @@ -0,0 +1 @@ +This is a test file! diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/tests/unit/logging/logger.test.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/tests/unit/logging/logger.test.ts new file mode 100644 index 000000000000..2e0b5fe5040c --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/tests/unit/logging/logger.test.ts @@ -0,0 +1,454 @@ +import { ConsoleLogger, createLogger, Logger, LogLevel } from "../../../src/core/logging/logger"; + +function createMockLogger() { + return { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; +} + +describe("Logger", () => { + describe("LogLevel", () => { + it("should have correct log levels", () => { + expect(LogLevel.Debug).toBe("debug"); + expect(LogLevel.Info).toBe("info"); + expect(LogLevel.Warn).toBe("warn"); + expect(LogLevel.Error).toBe("error"); + }); + }); + + describe("ConsoleLogger", () => { + let consoleLogger: ConsoleLogger; + let consoleSpy: { + debug: ReturnType; + info: ReturnType; + warn: ReturnType; + error: ReturnType; + }; + + beforeEach(() => { + consoleLogger = new ConsoleLogger(); + consoleSpy = { + debug: vi.spyOn(console, "debug").mockImplementation(() => {}), + info: vi.spyOn(console, "info").mockImplementation(() => {}), + warn: vi.spyOn(console, "warn").mockImplementation(() => {}), + error: vi.spyOn(console, "error").mockImplementation(() => {}), + }; + }); + + afterEach(() => { + consoleSpy.debug.mockRestore(); + consoleSpy.info.mockRestore(); + consoleSpy.warn.mockRestore(); + consoleSpy.error.mockRestore(); + }); + + it("should log debug messages", () => { + consoleLogger.debug("debug message", { data: "test" }); + expect(consoleSpy.debug).toHaveBeenCalledWith("debug message", { data: "test" }); + }); + + it("should log info messages", () => { + consoleLogger.info("info message", { data: "test" }); + expect(consoleSpy.info).toHaveBeenCalledWith("info message", { data: "test" }); + }); + + it("should log warn messages", () => { + consoleLogger.warn("warn message", { data: "test" }); + expect(consoleSpy.warn).toHaveBeenCalledWith("warn message", { data: "test" }); + }); + + it("should log error messages", () => { + consoleLogger.error("error message", { data: "test" }); + expect(consoleSpy.error).toHaveBeenCalledWith("error message", { data: "test" }); + }); + + it("should handle multiple arguments", () => { + consoleLogger.debug("message", "arg1", "arg2", { key: "value" }); + expect(consoleSpy.debug).toHaveBeenCalledWith("message", "arg1", "arg2", { key: "value" }); + }); + }); + + describe("Logger with level filtering", () => { + let mockLogger: { + debug: ReturnType; + info: ReturnType; + warn: ReturnType; + error: ReturnType; + }; + + beforeEach(() => { + mockLogger = createMockLogger(); + }); + + describe("Debug level", () => { + it("should log all levels when set to debug", () => { + const logger = new Logger({ + level: LogLevel.Debug, + logger: mockLogger, + silent: false, + }); + + logger.debug("debug"); + logger.info("info"); + logger.warn("warn"); + logger.error("error"); + + expect(mockLogger.debug).toHaveBeenCalledWith("debug"); + expect(mockLogger.info).toHaveBeenCalledWith("info"); + expect(mockLogger.warn).toHaveBeenCalledWith("warn"); + expect(mockLogger.error).toHaveBeenCalledWith("error"); + }); + + it("should report correct level checks", () => { + const logger = new Logger({ + level: LogLevel.Debug, + logger: mockLogger, + silent: false, + }); + + expect(logger.isDebug()).toBe(true); + expect(logger.isInfo()).toBe(true); + expect(logger.isWarn()).toBe(true); + expect(logger.isError()).toBe(true); + }); + }); + + describe("Info level", () => { + it("should log info, warn, and error when set to info", () => { + const logger = new Logger({ + level: LogLevel.Info, + logger: mockLogger, + silent: false, + }); + + logger.debug("debug"); + logger.info("info"); + logger.warn("warn"); + logger.error("error"); + + expect(mockLogger.debug).not.toHaveBeenCalled(); + expect(mockLogger.info).toHaveBeenCalledWith("info"); + expect(mockLogger.warn).toHaveBeenCalledWith("warn"); + expect(mockLogger.error).toHaveBeenCalledWith("error"); + }); + + it("should report correct level checks", () => { + const logger = new Logger({ + level: LogLevel.Info, + logger: mockLogger, + silent: false, + }); + + expect(logger.isDebug()).toBe(false); + expect(logger.isInfo()).toBe(true); + expect(logger.isWarn()).toBe(true); + expect(logger.isError()).toBe(true); + }); + }); + + describe("Warn level", () => { + it("should log warn and error when set to warn", () => { + const logger = new Logger({ + level: LogLevel.Warn, + logger: mockLogger, + silent: false, + }); + + logger.debug("debug"); + logger.info("info"); + logger.warn("warn"); + logger.error("error"); + + expect(mockLogger.debug).not.toHaveBeenCalled(); + expect(mockLogger.info).not.toHaveBeenCalled(); + expect(mockLogger.warn).toHaveBeenCalledWith("warn"); + expect(mockLogger.error).toHaveBeenCalledWith("error"); + }); + + it("should report correct level checks", () => { + const logger = new Logger({ + level: LogLevel.Warn, + logger: mockLogger, + silent: false, + }); + + expect(logger.isDebug()).toBe(false); + expect(logger.isInfo()).toBe(false); + expect(logger.isWarn()).toBe(true); + expect(logger.isError()).toBe(true); + }); + }); + + describe("Error level", () => { + it("should only log error when set to error", () => { + const logger = new Logger({ + level: LogLevel.Error, + logger: mockLogger, + silent: false, + }); + + logger.debug("debug"); + logger.info("info"); + logger.warn("warn"); + logger.error("error"); + + expect(mockLogger.debug).not.toHaveBeenCalled(); + expect(mockLogger.info).not.toHaveBeenCalled(); + expect(mockLogger.warn).not.toHaveBeenCalled(); + expect(mockLogger.error).toHaveBeenCalledWith("error"); + }); + + it("should report correct level checks", () => { + const logger = new Logger({ + level: LogLevel.Error, + logger: mockLogger, + silent: false, + }); + + expect(logger.isDebug()).toBe(false); + expect(logger.isInfo()).toBe(false); + expect(logger.isWarn()).toBe(false); + expect(logger.isError()).toBe(true); + }); + }); + + describe("Silent mode", () => { + it("should not log anything when silent is true", () => { + const logger = new Logger({ + level: LogLevel.Debug, + logger: mockLogger, + silent: true, + }); + + logger.debug("debug"); + logger.info("info"); + logger.warn("warn"); + logger.error("error"); + + expect(mockLogger.debug).not.toHaveBeenCalled(); + expect(mockLogger.info).not.toHaveBeenCalled(); + expect(mockLogger.warn).not.toHaveBeenCalled(); + expect(mockLogger.error).not.toHaveBeenCalled(); + }); + + it("should report all level checks as false when silent", () => { + const logger = new Logger({ + level: LogLevel.Debug, + logger: mockLogger, + silent: true, + }); + + expect(logger.isDebug()).toBe(false); + expect(logger.isInfo()).toBe(false); + expect(logger.isWarn()).toBe(false); + expect(logger.isError()).toBe(false); + }); + }); + + describe("shouldLog", () => { + it("should correctly determine if level should be logged", () => { + const logger = new Logger({ + level: LogLevel.Info, + logger: mockLogger, + silent: false, + }); + + expect(logger.shouldLog(LogLevel.Debug)).toBe(false); + expect(logger.shouldLog(LogLevel.Info)).toBe(true); + expect(logger.shouldLog(LogLevel.Warn)).toBe(true); + expect(logger.shouldLog(LogLevel.Error)).toBe(true); + }); + + it("should return false for all levels when silent", () => { + const logger = new Logger({ + level: LogLevel.Debug, + logger: mockLogger, + silent: true, + }); + + expect(logger.shouldLog(LogLevel.Debug)).toBe(false); + expect(logger.shouldLog(LogLevel.Info)).toBe(false); + expect(logger.shouldLog(LogLevel.Warn)).toBe(false); + expect(logger.shouldLog(LogLevel.Error)).toBe(false); + }); + }); + + describe("Multiple arguments", () => { + it("should pass multiple arguments to logger", () => { + const logger = new Logger({ + level: LogLevel.Debug, + logger: mockLogger, + silent: false, + }); + + logger.debug("message", "arg1", { key: "value" }, 123); + expect(mockLogger.debug).toHaveBeenCalledWith("message", "arg1", { key: "value" }, 123); + }); + }); + }); + + describe("createLogger", () => { + it("should return default logger when no config provided", () => { + const logger = createLogger(); + expect(logger).toBeInstanceOf(Logger); + }); + + it("should return same logger instance when Logger is passed", () => { + const customLogger = new Logger({ + level: LogLevel.Debug, + logger: new ConsoleLogger(), + silent: false, + }); + + const result = createLogger(customLogger); + expect(result).toBe(customLogger); + }); + + it("should create logger with custom config", () => { + const mockLogger = createMockLogger(); + + const logger = createLogger({ + level: LogLevel.Warn, + logger: mockLogger, + silent: false, + }); + + expect(logger).toBeInstanceOf(Logger); + logger.warn("test"); + expect(mockLogger.warn).toHaveBeenCalledWith("test"); + }); + + it("should use default values for missing config", () => { + const logger = createLogger({}); + expect(logger).toBeInstanceOf(Logger); + }); + + it("should override default level", () => { + const mockLogger = createMockLogger(); + + const logger = createLogger({ + level: LogLevel.Debug, + logger: mockLogger, + silent: false, + }); + + logger.debug("test"); + expect(mockLogger.debug).toHaveBeenCalledWith("test"); + }); + + it("should override default silent mode", () => { + const mockLogger = createMockLogger(); + + const logger = createLogger({ + logger: mockLogger, + silent: false, + }); + + logger.info("test"); + expect(mockLogger.info).toHaveBeenCalledWith("test"); + }); + + it("should use provided logger implementation", () => { + const customLogger = createMockLogger(); + + const logger = createLogger({ + logger: customLogger, + level: LogLevel.Debug, + silent: false, + }); + + logger.debug("test"); + expect(customLogger.debug).toHaveBeenCalledWith("test"); + }); + + it("should default to silent: true", () => { + const mockLogger = createMockLogger(); + + const logger = createLogger({ + logger: mockLogger, + level: LogLevel.Debug, + }); + + logger.debug("test"); + expect(mockLogger.debug).not.toHaveBeenCalled(); + }); + }); + + describe("Default logger", () => { + it("should have silent: true by default", () => { + const logger = createLogger(); + expect(logger.shouldLog(LogLevel.Info)).toBe(false); + }); + + it("should not log when using default logger", () => { + const logger = createLogger(); + + logger.info("test"); + expect(logger.isInfo()).toBe(false); + }); + }); + + describe("Edge cases", () => { + it("should handle empty message", () => { + const mockLogger = createMockLogger(); + + const logger = new Logger({ + level: LogLevel.Debug, + logger: mockLogger, + silent: false, + }); + + logger.debug(""); + expect(mockLogger.debug).toHaveBeenCalledWith(""); + }); + + it("should handle no arguments", () => { + const mockLogger = createMockLogger(); + + const logger = new Logger({ + level: LogLevel.Debug, + logger: mockLogger, + silent: false, + }); + + logger.debug("message"); + expect(mockLogger.debug).toHaveBeenCalledWith("message"); + }); + + it("should handle complex objects", () => { + const mockLogger = createMockLogger(); + + const logger = new Logger({ + level: LogLevel.Debug, + logger: mockLogger, + silent: false, + }); + + const complexObject = { + nested: { key: "value" }, + array: [1, 2, 3], + fn: () => "test", + }; + + logger.debug("message", complexObject); + expect(mockLogger.debug).toHaveBeenCalledWith("message", complexObject); + }); + + it("should handle errors as arguments", () => { + const mockLogger = createMockLogger(); + + const logger = new Logger({ + level: LogLevel.Error, + logger: mockLogger, + silent: false, + }); + + const error = new Error("Test error"); + logger.error("Error occurred", error); + expect(mockLogger.error).toHaveBeenCalledWith("Error occurred", error); + }); + }); +}); diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/tests/unit/url/join.test.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/tests/unit/url/join.test.ts new file mode 100644 index 000000000000..123488f084ea --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/tests/unit/url/join.test.ts @@ -0,0 +1,284 @@ +import { join } from "../../../src/core/url/index"; + +describe("join", () => { + interface TestCase { + description: string; + base: string; + segments: string[]; + expected: string; + } + + describe("basic functionality", () => { + const basicTests: TestCase[] = [ + { description: "should return empty string for empty base", base: "", segments: [], expected: "" }, + { + description: "should return empty string for empty base with path", + base: "", + segments: ["path"], + expected: "", + }, + { + description: "should handle single segment", + base: "base", + segments: ["segment"], + expected: "base/segment", + }, + { + description: "should handle single segment with trailing slash on base", + base: "base/", + segments: ["segment"], + expected: "base/segment", + }, + { + description: "should handle single segment with leading slash", + base: "base", + segments: ["/segment"], + expected: "base/segment", + }, + { + description: "should handle single segment with both slashes", + base: "base/", + segments: ["/segment"], + expected: "base/segment", + }, + { + description: "should handle multiple segments", + base: "base", + segments: ["path1", "path2", "path3"], + expected: "base/path1/path2/path3", + }, + { + description: "should handle multiple segments with slashes", + base: "base/", + segments: ["/path1/", "/path2/", "/path3/"], + expected: "base/path1/path2/path3/", + }, + ]; + + basicTests.forEach(({ description, base, segments, expected }) => { + it(description, () => { + expect(join(base, ...segments)).toBe(expected); + }); + }); + }); + + describe("URL handling", () => { + const urlTests: TestCase[] = [ + { + description: "should handle absolute URLs", + base: "https://example.com", + segments: ["api", "v1"], + expected: "https://example.com/api/v1", + }, + { + description: "should handle absolute URLs with slashes", + base: "https://example.com/", + segments: ["/api/", "/v1/"], + expected: "https://example.com/api/v1/", + }, + { + description: "should handle absolute URLs with base path", + base: "https://example.com/base", + segments: ["api", "v1"], + expected: "https://example.com/base/api/v1", + }, + { + description: "should preserve URL query parameters", + base: "https://example.com?query=1", + segments: ["api"], + expected: "https://example.com/api?query=1", + }, + { + description: "should preserve URL fragments", + base: "https://example.com#fragment", + segments: ["api"], + expected: "https://example.com/api#fragment", + }, + { + description: "should preserve URL query and fragments", + base: "https://example.com?query=1#fragment", + segments: ["api"], + expected: "https://example.com/api?query=1#fragment", + }, + { + description: "should handle http protocol", + base: "http://example.com", + segments: ["api"], + expected: "http://example.com/api", + }, + { + description: "should handle ftp protocol", + base: "ftp://example.com", + segments: ["files"], + expected: "ftp://example.com/files", + }, + { + description: "should handle ws protocol", + base: "ws://example.com", + segments: ["socket"], + expected: "ws://example.com/socket", + }, + { + description: "should fallback to path joining for malformed URLs", + base: "not-a-url://", + segments: ["path"], + expected: "not-a-url:///path", + }, + ]; + + urlTests.forEach(({ description, base, segments, expected }) => { + it(description, () => { + expect(join(base, ...segments)).toBe(expected); + }); + }); + }); + + describe("edge cases", () => { + const edgeCaseTests: TestCase[] = [ + { + description: "should handle empty segments", + base: "base", + segments: ["", "path"], + expected: "base/path", + }, + { + description: "should handle null segments", + base: "base", + segments: [null as any, "path"], + expected: "base/path", + }, + { + description: "should handle undefined segments", + base: "base", + segments: [undefined as any, "path"], + expected: "base/path", + }, + { + description: "should handle segments with only single slash", + base: "base", + segments: ["/", "path"], + expected: "base/path", + }, + { + description: "should handle segments with only double slash", + base: "base", + segments: ["//", "path"], + expected: "base/path", + }, + { + description: "should handle base paths with trailing slashes", + base: "base/", + segments: ["path"], + expected: "base/path", + }, + { + description: "should handle complex nested paths", + base: "api/v1/", + segments: ["/users/", "/123/", "/profile"], + expected: "api/v1/users/123/profile", + }, + ]; + + edgeCaseTests.forEach(({ description, base, segments, expected }) => { + it(description, () => { + expect(join(base, ...segments)).toBe(expected); + }); + }); + }); + + describe("real-world scenarios", () => { + const realWorldTests: TestCase[] = [ + { + description: "should handle API endpoint construction", + base: "https://api.example.com/v1", + segments: ["users", "123", "posts"], + expected: "https://api.example.com/v1/users/123/posts", + }, + { + description: "should handle file path construction", + base: "/var/www", + segments: ["html", "assets", "images"], + expected: "/var/www/html/assets/images", + }, + { + description: "should handle relative path construction", + base: "../parent", + segments: ["child", "grandchild"], + expected: "../parent/child/grandchild", + }, + { + description: "should handle Windows-style paths", + base: "C:\\Users", + segments: ["Documents", "file.txt"], + expected: "C:\\Users/Documents/file.txt", + }, + ]; + + realWorldTests.forEach(({ description, base, segments, expected }) => { + it(description, () => { + expect(join(base, ...segments)).toBe(expected); + }); + }); + }); + + describe("performance scenarios", () => { + it("should handle many segments efficiently", () => { + const segments = Array(100).fill("segment"); + const result = join("base", ...segments); + expect(result).toBe(`base/${segments.join("/")}`); + }); + + it("should handle long URLs", () => { + const longPath = "a".repeat(1000); + expect(join("https://example.com", longPath)).toBe(`https://example.com/${longPath}`); + }); + }); + + describe("trailing slash preservation", () => { + const trailingSlashTests: TestCase[] = [ + { + description: + "should preserve trailing slash on final result when base has trailing slash and no segments", + base: "https://api.example.com/", + segments: [], + expected: "https://api.example.com/", + }, + { + description: "should preserve trailing slash on v1 path", + base: "https://api.example.com/v1/", + segments: [], + expected: "https://api.example.com/v1/", + }, + { + description: "should preserve trailing slash when last segment has trailing slash", + base: "https://api.example.com", + segments: ["users/"], + expected: "https://api.example.com/users/", + }, + { + description: "should preserve trailing slash with relative path", + base: "api/v1", + segments: ["users/"], + expected: "api/v1/users/", + }, + { + description: "should preserve trailing slash with multiple segments", + base: "https://api.example.com", + segments: ["v1", "collections/"], + expected: "https://api.example.com/v1/collections/", + }, + { + description: "should preserve trailing slash with base path", + base: "base", + segments: ["path1", "path2/"], + expected: "base/path1/path2/", + }, + ]; + + trailingSlashTests.forEach(({ description, base, segments, expected }) => { + it(description, () => { + expect(join(base, ...segments)).toBe(expected); + }); + }); + }); +}); diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/tests/unit/url/qs.test.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/tests/unit/url/qs.test.ts new file mode 100644 index 000000000000..42cdffb9e5ea --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/tests/unit/url/qs.test.ts @@ -0,0 +1,278 @@ +import { toQueryString } from "../../../src/core/url/index"; + +describe("Test qs toQueryString", () => { + interface BasicTestCase { + description: string; + input: any; + expected: string; + } + + describe("Basic functionality", () => { + const basicTests: BasicTestCase[] = [ + { description: "should return empty string for null", input: null, expected: "" }, + { description: "should return empty string for undefined", input: undefined, expected: "" }, + { description: "should return empty string for string primitive", input: "hello", expected: "" }, + { description: "should return empty string for number primitive", input: 42, expected: "" }, + { description: "should return empty string for true boolean", input: true, expected: "" }, + { description: "should return empty string for false boolean", input: false, expected: "" }, + { description: "should handle empty objects", input: {}, expected: "" }, + { + description: "should handle simple key-value pairs", + input: { name: "John", age: 30 }, + expected: "name=John&age=30", + }, + ]; + + basicTests.forEach(({ description, input, expected }) => { + it(description, () => { + expect(toQueryString(input)).toBe(expected); + }); + }); + }); + + describe("Array handling", () => { + interface ArrayTestCase { + description: string; + input: any; + options?: { arrayFormat?: "repeat" | "indices" }; + expected: string; + } + + const arrayTests: ArrayTestCase[] = [ + { + description: "should handle arrays with indices format (default)", + input: { items: ["a", "b", "c"] }, + expected: "items%5B0%5D=a&items%5B1%5D=b&items%5B2%5D=c", + }, + { + description: "should handle arrays with repeat format", + input: { items: ["a", "b", "c"] }, + options: { arrayFormat: "repeat" }, + expected: "items=a&items=b&items=c", + }, + { + description: "should handle empty arrays", + input: { items: [] }, + expected: "", + }, + { + description: "should handle arrays with mixed types", + input: { mixed: ["string", 42, true, false] }, + expected: "mixed%5B0%5D=string&mixed%5B1%5D=42&mixed%5B2%5D=true&mixed%5B3%5D=false", + }, + { + description: "should handle arrays with objects", + input: { users: [{ name: "John" }, { name: "Jane" }] }, + expected: "users%5B0%5D%5Bname%5D=John&users%5B1%5D%5Bname%5D=Jane", + }, + { + description: "should handle arrays with objects in repeat format", + input: { users: [{ name: "John" }, { name: "Jane" }] }, + options: { arrayFormat: "repeat" }, + expected: "users%5Bname%5D=John&users%5Bname%5D=Jane", + }, + ]; + + arrayTests.forEach(({ description, input, options, expected }) => { + it(description, () => { + expect(toQueryString(input, options)).toBe(expected); + }); + }); + }); + + describe("Nested objects", () => { + const nestedTests: BasicTestCase[] = [ + { + description: "should handle nested objects", + input: { user: { name: "John", age: 30 } }, + expected: "user%5Bname%5D=John&user%5Bage%5D=30", + }, + { + description: "should handle deeply nested objects", + input: { user: { profile: { name: "John", settings: { theme: "dark" } } } }, + expected: "user%5Bprofile%5D%5Bname%5D=John&user%5Bprofile%5D%5Bsettings%5D%5Btheme%5D=dark", + }, + { + description: "should handle empty nested objects", + input: { user: {} }, + expected: "", + }, + ]; + + nestedTests.forEach(({ description, input, expected }) => { + it(description, () => { + expect(toQueryString(input)).toBe(expected); + }); + }); + }); + + describe("Encoding", () => { + interface EncodingTestCase { + description: string; + input: any; + options?: { encode?: boolean }; + expected: string; + } + + const encodingTests: EncodingTestCase[] = [ + { + description: "should encode by default", + input: { name: "John Doe", email: "john@example.com" }, + expected: "name=John%20Doe&email=john%40example.com", + }, + { + description: "should not encode when encode is false", + input: { name: "John Doe", email: "john@example.com" }, + options: { encode: false }, + expected: "name=John Doe&email=john@example.com", + }, + { + description: "should encode special characters in keys", + input: { "user name": "John", "email[primary]": "john@example.com" }, + expected: "user%20name=John&email%5Bprimary%5D=john%40example.com", + }, + { + description: "should not encode special characters in keys when encode is false", + input: { "user name": "John", "email[primary]": "john@example.com" }, + options: { encode: false }, + expected: "user name=John&email[primary]=john@example.com", + }, + ]; + + encodingTests.forEach(({ description, input, options, expected }) => { + it(description, () => { + expect(toQueryString(input, options)).toBe(expected); + }); + }); + }); + + describe("Mixed scenarios", () => { + interface MixedTestCase { + description: string; + input: any; + options?: { arrayFormat?: "repeat" | "indices" }; + expected: string; + } + + const mixedTests: MixedTestCase[] = [ + { + description: "should handle complex nested structures", + input: { + filters: { + status: ["active", "pending"], + category: { + type: "electronics", + subcategories: ["phones", "laptops"], + }, + }, + sort: { field: "name", direction: "asc" }, + }, + expected: + "filters%5Bstatus%5D%5B0%5D=active&filters%5Bstatus%5D%5B1%5D=pending&filters%5Bcategory%5D%5Btype%5D=electronics&filters%5Bcategory%5D%5Bsubcategories%5D%5B0%5D=phones&filters%5Bcategory%5D%5Bsubcategories%5D%5B1%5D=laptops&sort%5Bfield%5D=name&sort%5Bdirection%5D=asc", + }, + { + description: "should handle complex nested structures with repeat format", + input: { + filters: { + status: ["active", "pending"], + category: { + type: "electronics", + subcategories: ["phones", "laptops"], + }, + }, + sort: { field: "name", direction: "asc" }, + }, + options: { arrayFormat: "repeat" }, + expected: + "filters%5Bstatus%5D=active&filters%5Bstatus%5D=pending&filters%5Bcategory%5D%5Btype%5D=electronics&filters%5Bcategory%5D%5Bsubcategories%5D=phones&filters%5Bcategory%5D%5Bsubcategories%5D=laptops&sort%5Bfield%5D=name&sort%5Bdirection%5D=asc", + }, + { + description: "should handle arrays with null/undefined values", + input: { items: ["a", null, "c", undefined, "e"] }, + expected: "items%5B0%5D=a&items%5B1%5D=&items%5B2%5D=c&items%5B4%5D=e", + }, + { + description: "should handle objects with null/undefined values", + input: { name: "John", age: null, email: undefined, active: true }, + expected: "name=John&age=&active=true", + }, + ]; + + mixedTests.forEach(({ description, input, options, expected }) => { + it(description, () => { + expect(toQueryString(input, options)).toBe(expected); + }); + }); + }); + + describe("Edge cases", () => { + const edgeCaseTests: BasicTestCase[] = [ + { + description: "should handle numeric keys", + input: { "0": "zero", "1": "one" }, + expected: "0=zero&1=one", + }, + { + description: "should handle boolean values in objects", + input: { enabled: true, disabled: false }, + expected: "enabled=true&disabled=false", + }, + { + description: "should handle empty strings", + input: { name: "", description: "test" }, + expected: "name=&description=test", + }, + { + description: "should handle zero values", + input: { count: 0, price: 0.0 }, + expected: "count=0&price=0", + }, + { + description: "should handle arrays with empty strings", + input: { items: ["a", "", "c"] }, + expected: "items%5B0%5D=a&items%5B1%5D=&items%5B2%5D=c", + }, + ]; + + edgeCaseTests.forEach(({ description, input, expected }) => { + it(description, () => { + expect(toQueryString(input)).toBe(expected); + }); + }); + }); + + describe("Options combinations", () => { + interface OptionsTestCase { + description: string; + input: any; + options?: { arrayFormat?: "repeat" | "indices"; encode?: boolean }; + expected: string; + } + + const optionsTests: OptionsTestCase[] = [ + { + description: "should respect both arrayFormat and encode options", + input: { items: ["a & b", "c & d"] }, + options: { arrayFormat: "repeat", encode: false }, + expected: "items=a & b&items=c & d", + }, + { + description: "should use default options when none provided", + input: { items: ["a", "b"] }, + expected: "items%5B0%5D=a&items%5B1%5D=b", + }, + { + description: "should merge provided options with defaults", + input: { items: ["a", "b"], name: "John Doe" }, + options: { encode: false }, + expected: "items[0]=a&items[1]=b&name=John Doe", + }, + ]; + + optionsTests.forEach(({ description, input, options, expected }) => { + it(description, () => { + expect(toQueryString(input, options)).toBe(expected); + }); + }); + }); +}); diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/tests/wire/.gitkeep b/seed/ts-sdk/any-auth/discriminated-union-auth/tests/wire/.gitkeep new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/tests/wire/auth.test.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/tests/wire/auth.test.ts new file mode 100644 index 000000000000..7eeec9e31c91 --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/tests/wire/auth.test.ts @@ -0,0 +1,48 @@ +// This file was auto-generated by Fern from our API Definition. + +import { SeedAnyAuthClient } from "../../src/Client"; +import { mockServerPool } from "../mock-server/MockServerPool"; +import { mockOAuth } from "./mockAuth"; + +describe("AuthClient", () => { + test("getToken", async () => { + const server = mockServerPool.createServer(); + mockOAuth(server); + + const client = new SeedAnyAuthClient({ + maxRetries: 0, + token: "test", + apiKey: "test", + clientId: "client_id", + clientSecret: "client_secret", + environment: server.baseUrl, + }); + const rawRequestBody = { + client_id: "client_id", + client_secret: "client_secret", + audience: "https://api.example.com", + grant_type: "client_credentials", + scope: "scope", + }; + const rawResponseBody = { access_token: "access_token", expires_in: 1, refresh_token: "refresh_token" }; + server + .mockEndpoint() + .post("/token") + .jsonBody(rawRequestBody) + .respondWith() + .statusCode(200) + .jsonBody(rawResponseBody) + .build(); + + const response = await client.auth.getToken({ + client_id: "client_id", + client_secret: "client_secret", + scope: "scope", + }); + expect(response).toEqual({ + access_token: "access_token", + expires_in: 1, + refresh_token: "refresh_token", + }); + }); +}); diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/tests/wire/mockAuth.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/tests/wire/mockAuth.ts new file mode 100644 index 000000000000..4a488003681c --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/tests/wire/mockAuth.ts @@ -0,0 +1,21 @@ +// This file was auto-generated by Fern from our API Definition. + +import type { MockServer } from "../mock-server/MockServer"; + +export function mockOAuth(server: MockServer): void { + const rawRequestBody = { + client_id: "client_id", + client_secret: "client_secret", + audience: "https://api.example.com", + grant_type: "client_credentials", + }; + const rawResponseBody = { access_token: "access_token", expires_in: 1, refresh_token: "refresh_token" }; + server + .mockEndpoint() + .post("/token") + .jsonBody(rawRequestBody) + .respondWith() + .statusCode(200) + .jsonBody(rawResponseBody) + .build(); +} diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/tests/wire/user.test.ts b/seed/ts-sdk/any-auth/discriminated-union-auth/tests/wire/user.test.ts new file mode 100644 index 000000000000..8365ab4f36e5 --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/tests/wire/user.test.ts @@ -0,0 +1,71 @@ +// This file was auto-generated by Fern from our API Definition. + +import { SeedAnyAuthClient } from "../../src/Client"; +import { mockServerPool } from "../mock-server/MockServerPool"; +import { mockOAuth } from "./mockAuth"; + +describe("UserClient", () => { + test("get", async () => { + const server = mockServerPool.createServer(); + mockOAuth(server); + + const client = new SeedAnyAuthClient({ + maxRetries: 0, + token: "test", + apiKey: "test", + clientId: "client_id", + clientSecret: "client_secret", + environment: server.baseUrl, + }); + + const rawResponseBody = [ + { id: "id", name: "name" }, + { id: "id", name: "name" }, + ]; + server.mockEndpoint().post("/users").respondWith().statusCode(200).jsonBody(rawResponseBody).build(); + + const response = await client.user.get(); + expect(response).toEqual([ + { + id: "id", + name: "name", + }, + { + id: "id", + name: "name", + }, + ]); + }); + + test("getAdmins", async () => { + const server = mockServerPool.createServer(); + mockOAuth(server); + + const client = new SeedAnyAuthClient({ + maxRetries: 0, + token: "test", + apiKey: "test", + clientId: "client_id", + clientSecret: "client_secret", + environment: server.baseUrl, + }); + + const rawResponseBody = [ + { id: "id", name: "name" }, + { id: "id", name: "name" }, + ]; + server.mockEndpoint().get("/admins").respondWith().statusCode(200).jsonBody(rawResponseBody).build(); + + const response = await client.user.getAdmins(); + expect(response).toEqual([ + { + id: "id", + name: "name", + }, + { + id: "id", + name: "name", + }, + ]); + }); +}); diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/tsconfig.base.json b/seed/ts-sdk/any-auth/discriminated-union-auth/tsconfig.base.json new file mode 100644 index 000000000000..d7627675de20 --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/tsconfig.base.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "extendedDiagnostics": true, + "strict": true, + "target": "ES6", + "moduleResolution": "node", + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true, + "outDir": "dist", + "rootDir": "src", + "baseUrl": "src", + "isolatedModules": true, + "isolatedDeclarations": true + }, + "include": ["src"], + "exclude": [] +} diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/tsconfig.cjs.json b/seed/ts-sdk/any-auth/discriminated-union-auth/tsconfig.cjs.json new file mode 100644 index 000000000000..5c11446f5984 --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/tsconfig.cjs.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "module": "CommonJS", + "outDir": "dist/cjs" + }, + "include": ["src"], + "exclude": [] +} diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/tsconfig.esm.json b/seed/ts-sdk/any-auth/discriminated-union-auth/tsconfig.esm.json new file mode 100644 index 000000000000..6ce909748b2c --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/tsconfig.esm.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "module": "esnext", + "outDir": "dist/esm", + "verbatimModuleSyntax": true + }, + "include": ["src"], + "exclude": [] +} diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/tsconfig.json b/seed/ts-sdk/any-auth/discriminated-union-auth/tsconfig.json new file mode 100644 index 000000000000..d77fdf00d259 --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "./tsconfig.cjs.json" +} diff --git a/seed/ts-sdk/any-auth/discriminated-union-auth/vitest.config.mts b/seed/ts-sdk/any-auth/discriminated-union-auth/vitest.config.mts new file mode 100644 index 000000000000..ba2ec4f9d45a --- /dev/null +++ b/seed/ts-sdk/any-auth/discriminated-union-auth/vitest.config.mts @@ -0,0 +1,28 @@ +import { defineConfig } from "vitest/config"; +export default defineConfig({ + test: { + projects: [ + { + test: { + globals: true, + name: "unit", + environment: "node", + root: "./tests", + include: ["**/*.test.{js,ts,jsx,tsx}"], + exclude: ["wire/**"], + setupFiles: ["./setup.ts"], + }, + }, + { + test: { + globals: true, + name: "wire", + environment: "node", + root: "./tests/wire", + setupFiles: ["../setup.ts", "../mock-server/setup.ts"], + }, + }, + ], + passWithNoTests: true, + }, +}); diff --git a/seed/ts-sdk/any-auth/generate-endpoint-metadata/src/auth/OAuthAuthProvider.ts b/seed/ts-sdk/any-auth/generate-endpoint-metadata/src/auth/OAuthAuthProvider.ts index c10d30e44ad8..5fd6266fe23e 100644 --- a/seed/ts-sdk/any-auth/generate-endpoint-metadata/src/auth/OAuthAuthProvider.ts +++ b/seed/ts-sdk/any-auth/generate-endpoint-metadata/src/auth/OAuthAuthProvider.ts @@ -14,14 +14,14 @@ export class OAuthAuthProvider implements core.AuthProvider { private _expiresAt: Date; private _refreshPromise: Promise | undefined; - constructor(options: OAuthAuthProvider.Options) { + constructor(options: OAuthAuthProvider.Options & OAuthAuthProvider.AuthOptions) { this._clientId = options.clientId; this._clientSecret = options.clientSecret; this._authClient = new AuthClient(options); this._expiresAt = new Date(); } - public static canCreate(options: OAuthAuthProvider.Options): boolean { + public static canCreate(options: OAuthAuthProvider.Options & Partial): boolean { return ( (options.clientId != null || process.env?.MY_CLIENT_ID != null) && (options.clientSecret != null || process.env?.MY_CLIENT_SECRET != null) diff --git a/seed/ts-sdk/any-auth/no-custom-config/src/auth/OAuthAuthProvider.ts b/seed/ts-sdk/any-auth/no-custom-config/src/auth/OAuthAuthProvider.ts index ce63882cd0f3..922899f7aba2 100644 --- a/seed/ts-sdk/any-auth/no-custom-config/src/auth/OAuthAuthProvider.ts +++ b/seed/ts-sdk/any-auth/no-custom-config/src/auth/OAuthAuthProvider.ts @@ -14,14 +14,14 @@ export class OAuthAuthProvider implements core.AuthProvider { private _expiresAt: Date; private _refreshPromise: Promise | undefined; - constructor(options: OAuthAuthProvider.Options) { + constructor(options: OAuthAuthProvider.Options & OAuthAuthProvider.AuthOptions) { this._clientId = options.clientId; this._clientSecret = options.clientSecret; this._authClient = new AuthClient(options); this._expiresAt = new Date(); } - public static canCreate(options: OAuthAuthProvider.Options): boolean { + public static canCreate(options: OAuthAuthProvider.Options & Partial): boolean { return ( (options.clientId != null || process.env?.MY_CLIENT_ID != null) && (options.clientSecret != null || process.env?.MY_CLIENT_SECRET != null) diff --git a/seed/ts-sdk/seed.yml b/seed/ts-sdk/seed.yml index 22cba360ec6e..404848a306a7 100644 --- a/seed/ts-sdk/seed.yml +++ b/seed/ts-sdk/seed.yml @@ -353,6 +353,9 @@ fixtures: - outputFolder: generate-endpoint-metadata customConfig: generateEndpointMetadata: true + - outputFolder: discriminated-union-auth + customConfig: + anyAuth: v2 simple-api: - outputFolder: use-yarn customConfig: